Skip to content

RS-22971: Export a native date axis when category labels are dates#92

Open
chschan wants to merge 10 commits into
masterfrom
RS-22971
Open

RS-22971: Export a native date axis when category labels are dates#92
chschan wants to merge 10 commits into
masterfrom
RS-22971

Conversation

@chschan

@chschan chschan commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What & why

R side of the PowerPoint date axis export feature. When a chart's category labels are dates, Q can now export a native PowerPoint date axis (c:dateAx) instead of text categories — but only if it receives the underlying dates, because R always renders category labels as strings.

This change makes flipChart:

  1. Declare the date axisgetPPTSettings sets ChartSettings$PrimaryAxis$AxisType = "Date" (+ a date NumberFormat) when the category labels are dates, for non-pie/non-scatter charts.
  2. Supply the underlying dates — attaches a category.dates attribute (numeric date serials) plus category.date.format to ChartData, which Q reads to build the numeric numRef/dateAx.

How it works

  • Capture happens in addCategoryDateAxisAttributes, called on the final prepared data just before PrepareData returns — so it covers every input path (table row labels, raw date variables, pasted) uniformly, and nothing downstream can strip the attribute.
  • Handles the common "Automatic" date format (auto-detects the parse, leaves display labels unchanged) as well as explicit US/International (which also reformats the display labels, as before). "No date" opts out.

Tests

  • New test-chartsettings-dateaxis.R: asserts ChartSettings$PrimaryAxis$AxisType == "Date" and the date serials on ChartData for Column/Area/Line/Bar, end-to-end through PrepareData → CChart, for both date-rownamed tables and raw Date variables; plus negatives (no date attr, Pie, non-date labels).
  • All AI-generated and reviewed; verified passing locally with testthat (R 4.5.1), no regressions in test-preparedata.R / test-chartsettings.R.

Companion PR

Displayr/q#26940 (the C# side that consumes category.dates and emits the c:dateAx). This PR now checks the QFileFormat so it can be merged at any time, but the C# change is required for the exporting to fix.

chschan and others added 7 commits July 23, 2026 17:55
When category labels are dates, declare a date primary axis and carry the
underlying date serials to Q so PowerPoint exports a native date axis
(c:dateAx) instead of text categories:

- transformTable: when labels are dates (including the common "Automatic"
  case, which now auto-detects the format and leaves the display labels
  unchanged), attach "category.dates" serials and "category.date.format" to
  the data. Explicit US/International still reformats the labels as before.
- getPPTSettings: set PrimaryAxis$AxisType = "Date" (+ NumberFormat) when
  those dates are present, for non-pie/non-scatter charts.

Covers the table-with-date-rownames path (QTable-style inputs). Adds
test-chartsettings-dateaxis.R asserting the ChartSettings values end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first commit only captured dates on the table-with-date-rownames path
(via transformTable). Raw data with a Date variable is formatted to string
labels on a different path, so its dates were lost and it exported as text
categories.

Move the serial capture out of transformTable (restored to its original
label-reformatting role) into addCategoryDateAxisAttributes, called on the
final prepared data just before PrepareData returns. Because it runs last on
the finished labels, it covers every input path (tables, raw variables,
pasted) uniformly and nothing downstream can strip the attribute.

Adds a raw-data Date-variable end-to-end test alongside the table one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the date axis NumberFormat was always the locale default
("dd mmm yyyy" / "mmm dd yyyy"), ignoring the categories.tick.format the user
set via the Plugin (a d3/strftime format from flipChartBasics::ChartNumberFormat,
e.g. "%Y", "%m %d %Y"). convertToPPTNumFormat can't handle these — the "%" makes
it return a percentage code.

Add convertToPPTDateFormat to map a d3 date format to a PowerPoint date code by
token substitution, and use it for the date axis when the user set one; fall
back to the format PrepareData derived from the labels otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getPPTSettings sent PrimaryAxis$RotateLabels (a boolean), but Q's PptAxisSettings
has no such property — it has LabelsRotation (integer degrees). The name/type
mismatch has existed since 2020; Q silently ignores unknown settings, so category
axis label rotation from these charts was never actually exported. (Not a
regression from the date-axis work — surfaced while testing it.)

Send LabelsRotation in degrees instead: -categories.tick.angle (0 stays 0,
90 -> -90), matching the C# property and the AnalysisPlot export path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exported labels rotated the wrong way: categories.tick.angle was negated,
but PowerPoint's LabelsRotation is clockwise-positive (same as plotly's
tickangle), so the angle maps straight through. Drop the negation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only send PrimaryAxis$LabelsRotation when QFileFormatVersion > 28.06 (i.e. Q
can parse it, from file format 28.08) and the user set a non-horizontal angle.
Older versions of Q error the whole export on an unparseable LabelsRotation, so
this keeps their exports working when they recalculate the viz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ory"

New CChart parameter categories.axis.number.type (default "Automatic"), used
only for PowerPoint exporting. When set to "Category", date row labels are
exported as plain string categories instead of a native date axis, so an
explicit category choice is honoured. The plugin can plumb formCategoriesNumberType
through to it later; until then the default keeps current behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chschan
chschan requested a review from JustinCCYap July 24, 2026 02:46
@JustinCCYap

Copy link
Copy Markdown
Contributor

The AxisType = "Date" block needs the same Q-version gate as LabelsRotation — as written, merging this before the q PR is deployed breaks PowerPoint export of date-labelled charts on current Q.

The consuming path in q parses ChartSettings enums via Enum.Parse, which throws on unknown names (RexpToObject.GetEnum in RInternals: Invalid string for 'AxisType' enum: Date), and PptChartSettingsAndLabels.LoadRObjectSettings rethrows that as a UserException — so the whole export fails. Any Q that predates Displayr/q#26940 (i.e. all of production today) will error out exporting any chart whose category labels are dates, where it previously exported them fine as text categories. The PR description's "can be merged at any time" currently only holds for LabelsRotation.

Suggested fix in getPPTSettings:

  1. Wrap the res$PrimaryAxis$AxisType <- "Date" / NumberFormat assignments in the same q.file.format.version check used for LabelsRotation.
  2. Use >= 28.08 rather than > 28.06 for both gates: internal Q builds report master + 0.01 (28.07) and may predate the change — that's why q bumps the format version by 0.02.

(category.dates itself doesn't need gating — old Q ignores unknown attributes via TryGetAttr. Only the ChartSettings declaration goes through the throwing reflection path.)

Comment thread R/cchart.R
result <- d3format
for (token in names(tokens))
result <- gsub(token, tokens[[token]], result, fixed = TRUE)
result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unmapped strftime tokens survive substitution and end up verbatim in the PPT format code. The predefined "Date/Time" dropdown formats are all covered by the token table, but ChartNumberFormat returns a user-typed custom format verbatim, and d3-time-format accepts tokens outside the mapped set (%e, %-d, %q, %j, %L, %Z, ...). Any of these passes the grepl("%[A-Za-z]") date detection but is left untouched by the gsub loop.

The consequence isn't just a literal rendering: in an Excel/PowerPoint number format, each % multiplies the displayed value by 100, so a leftover token corrupts the axis labels (OADate serial x 100) rather than degrading gracefully.

Cheap guard — treat any incomplete conversion as "not a date format" so the caller falls back to category.date.format:

    for (token in names(tokens))
        result <- gsub(token, tokens[[token]], result, fixed = TRUE)
    if (grepl("%", result, fixed = TRUE))
        return(NULL)
    result

Worth a test case alongside the existing convertToPPTDateFormat ones (e.g. expect_null(convertToPPTDateFormat("%e %b %Y"))).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push. After the substitution loop, any remaining % (unmapped token or user-typed custom format) now returns NULL, so the caller falls back to category.date.format instead of emitting a corrupt x100 axis. Added expect_null cases for %e %b %Y, %-d %b %Y, and %j.

Older versions of Q throw when parsing an unknown AxisType enum value ("Date")
via Enum.Parse and fail the whole export of any date-labelled chart. Gate the
AxisType/NumberFormat date-axis block on the exporting Q's file format version,
the same as LabelsRotation. (category.dates stays ungated - unknown ChartData
attributes are ignored by old Q.)

Also switch both gates from > 28.06 to >= 28.08: internal builds that predate
the change report 28.07 (last const + 0.01), which > 28.06 would wrongly include.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chschan

chschan commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

The AxisType = "Date" block needs the same Q-version gate as LabelsRotation

Good catch, both applied in 3836edd:

  1. AxisType = "Date" / NumberFormat are now gated on QFileFormatVersion exactly like LabelsRotation (shared q.can.parse.date.axis flag), so older Q never receives the unparseable enum value and date-labelled charts keep exporting as text categories there. category.dates stays ungated as you noted (old Q ignores unknown ChartData attributes).
  2. Both gates now use >= 28.08 instead of > 28.06, so internal 28.07 builds that predate the change are excluded.

Added a test asserting the date-axis ChartSettings are withheld at 28.06 / 28.07 / no-version, and the positive date-axis tests now set QFileFormatVersion = 28.08.

Custom/uncommon d3 date formats (%e, %-d, %j, %L, %Z, ...) pass the %[A-Za-z]
date detection but aren't in the substitution table, so they survived verbatim.
A stray "%" in an Excel number-format code multiplies the value by 100, which
would corrupt the axis (OADate x 100) rather than degrade. Return NULL if any
"%" remains after substitution so the caller falls back to category.date.format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread R/cchart.R Outdated

# Also skip the horizontal/Automatic default angle (0), which needs no rotation.
if (q.can.parse.date.axis && isTRUE(args$categories.tick.angle != 0))
res$PrimaryAxis$LabelsRotation <- as.integer(args$categories.tick.angle)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as.integer() truncates fractional angles for no reason now — the companion q PR changed LabelsRotation from int to double precisely so it accepts reals, and its test parses both an R integer and an R real vector. Suggest as.numeric(args$categories.tick.angle) so a fractional tick angle survives the round trip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — now uses as.numeric(args$categories.tick.angle), so a fractional angle survives the round trip. I also fixed the q side: it was pre-rounding to a whole degree before ToTextBody (which took an int). Since PowerPoint stores text rotation in 60,000ths of a degree, I widened ToTextBody's rotation to double and round only at that conversion, so a fractional angle (e.g. 45.5°) now survives all the way to the exported axis. Added a test (45.5 on the flipChart side, and a q test asserting rot = 45.5 × 60000).

Comment thread R/cchart.R Outdated

# Also skip the horizontal/Automatic default angle (0), which needs no rotation.
if (q.can.parse.date.axis && isTRUE(args$categories.tick.angle != 0))
res$PrimaryAxis$LabelsRotation <- as.integer(args$categories.tick.angle)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has the rotation direction been checked manually in PowerPoint? Plotly's positive tickangle and PPT's positive body rotation are both clockwise, so the pass-through looks right on paper — but q's own convention for vertical category labels is -90 (bottom-to-top; see PptChartSettings.cs / PptChartSettingsAndLabels.cs), so an exported 90 would read top-to-bottom where q-native charts read bottom-to-top. Worth one visual comparison of a widget with categories.tick.angle = 90 against its export to confirm the labels match.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, checked manually

@JustinCCYap JustinCCYap left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just a minor comment about as.integer()

Q's LabelsRotation is now a double, so use as.numeric instead of as.integer -
a fractional categories.tick.angle now survives the round trip (and, with the
companion q change, all the way to the exported axis, since PowerPoint stores
rotation in 60,000ths of a degree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants