Skip to content

Show a tabular file as a picture, drawn the way the export writes it - #215

Merged
MikeAlhayek merged 2 commits into
CrestApps:mainfrom
JackTelford:worktree-tabular-preview-tool
Sep 21, 2026
Merged

MikeAlhayek merged 2 commits into
CrestApps:mainfrom
JackTelford:worktree-tabular-preview-tool

Conversation

@JackTelford

Copy link
Copy Markdown
Contributor

Previews a spreadsheet in the answer instead of making the reader download it first. Each worksheet is drawn as a figure — header band, lettered column strip, numbered row gutter, and a caption saying what was left out of the window.

The picture is drawn from the formatting the file is written with

Resolving formatting in two places is how a preview ends up showing a different header colour, or a raw 74612.15999999999 where the file shows $74,612.16. The resolution both tools need now lives in TabularFormattingResolver, and ExportTabularDataTool delegates to it, so the two cannot drift. Column formats inherited from the uploaded workbook are applied to the drawing too, which is what makes a column that was currency in the upload read as currency in the preview.

A workbook stores a number and a format code and leaves the presenting to whatever opens it, so the codes are read in TabularPreviewValueFormat. Currency, accounting, plain number, percent and date are covered; anything outside that vocabulary falls back to the general presentation rather than being guessed at.

The general presentation now rounds to the significant digits a spreadsheet shows, instead of printing a double to full binary precision — 74612.15999999999 was wrong even with no format applied.

Two faults found while building it

Read-your-own-write. Figures were addressed before the rows backing them were committed, so every picture arrived at an address that did not resolve yet. The document is now committed before its address is handed out.

The model would not write the markers that place the pictures, through three rounds of prompt hardening. The host now appends a marker for any servable image the answer did not name — the rule this codebase already applies to generated files, which are surfaced even when the model does not cite them.

Reading the picture

A figure is bounded to a fraction of its drawn size so a stack of sheets does not push the conversation off screen, which leaves a grid at roughly half size. Clicking one enlarges it (~45% to ~72%, undistorted).

The download sits over the picture's corner rather than on a line of its own — that band cost a row of vertical space per sheet. It saves a PNG, converted in the browser from the picture already loaded: no second fetch, and nothing converted for a picture nobody saves. Every failure path falls back to the SVG the server holds.

Verification

  • TabularPreviewValueFormat covered by 15 cases against the format codes a real workbook carries: $74,612.16, $0.00, -$81,065.04, (1,234.50), 12.3%, 09-01-26 — all pass.
  • Zoom measured across three sheets at 1280x900: undistorted, 71-73%, opens and closes, thumbnail unchanged.
  • Hover download: 0px added height, 51 ms conversion, real PNG magic bytes, canvas untainted.
  • Generated .xlsx validated with OpenXmlValidator — 0 errors; bytes served match bytes on disk by SHA-256.

The second commit fixes a check that located the figure wrapper by the first mention of its class name in the file. That is the markup only while no rule or selector is written above it, so adding a stylesheet for the figures made it read a CSS selector instead. It now searches for the opening tag and checks every figure a file emits.

Known gaps

  • The SQL-query preview path gets the correct header colour and the rounding fix, but not per-column formats — attributing a query to a source table needs the export's sql-matching logic.
  • ai-chat.js and chat-interaction.js render the same figure markup for the other chat surfaces and still need the zoom and PNG-download treatment, which requires an asset rebuild.

🤖 Generated with Claude Code

A reader handed a spreadsheet had no way to see it without downloading it
first. Each worksheet is now drawn as a figure in the answer: a header band,
a lettered column strip, a numbered row gutter, and a caption saying plainly
what was left out of the window.

The picture is drawn from the same formatting the workbook is written with.
Resolving that in two places is how a preview ends up showing a different
header colour, or a raw 74612.15999999999 where the file shows $74,612.16, so
the resolution both tools need now lives in TabularFormattingResolver and the
export delegates to it. Column formats inherited from the uploaded file are
applied to the drawing too, which is what makes a column that was currency in
the upload read as currency in the preview.

The value a workbook stores is a number and a format code, and the presenting
is left to whatever opens it, so the codes are read here. Currency, accounting,
plain number, percent and date are covered; a code outside that vocabulary
falls back to the general presentation rather than being guessed at. That
general presentation now rounds to the significant digits a spreadsheet shows
instead of printing a double to full binary precision.

Two faults found while building it:

The figures were addressed before the rows that back them were committed, so
every picture arrived at an address that did not resolve yet. The document is
now committed before its address is handed out.

The model would not write the markers that place the pictures, through three
rounds of asking. The host now appends a marker for any servable image the
answer did not name, which is the rule this codebase already applies to a
generated file: it is surfaced even when the model does not cite it.

A picture is bounded to a fraction of its drawn size so a stack of sheets does
not push the conversation off screen, which leaves a spreadsheet grid at about
half size. Clicking one enlarges it. The download sits over the picture's
corner rather than on a line of its own, and saves a PNG converted from the
picture the page has already loaded, so nothing is fetched twice and nothing
is converted for a picture nobody saves.
…f its name

The check reads back which element a figure is wrapped in, because a marker is
often written mid-sentence and a <div> tears the paragraph apart. It located
that element by the first mention of the class name in the file, which is the
markup only for as long as no rule or selector is written above it. Adding a
stylesheet for the figures moved the first mention into CSS and the check
started reading a selector, so it failed on a file whose markup it had never
looked at.

It now searches for the opening tag itself, and checks every figure the file
emits rather than only the one that happens to come first.
@MikeAlhayek
MikeAlhayek merged commit 792a830 into CrestApps:main Sep 21, 2026
10 checks passed
JackTelford pushed a commit to JackTelford/CrestApps.Core that referenced this pull request Sep 22, 2026
The MVC chat sample moved its download onto the picture and gained
click-to-enlarge in CrestApps#215, but it did both inline in its own view. The shared
chat UI every other surface renders with -- the chat page, the widget and the
chat-interaction page -- kept the old shape: a button on a row of its own
beneath the picture, and a thumbnail that could not be opened.

A figure is capped to a fraction of the viewport so a stack of them does not
push the conversation off screen, which makes every one of them a thumbnail;
in the widget, 380px wide, more so. So the two things a reader wants are to
see it larger and to keep a copy, and the second was costing a band of
vertical space under every figure to offer.

The download is now drawn over the picture's lower corner and revealed by
pointing at the figure, on a pale ground of its own because it sits on
whatever the drawing puts in that corner. focus-within keeps it reachable by
keyboard, and a touch screen -- which has no hover to reveal it with -- simply
always shows it. The link is otherwise untouched: same address, same download
attribute, so the existing data-URI interception still names the saved file
the way it did.

Enlarging is wired to medium-zoom on the same deferred pass as the
broken-image handler, because a picture arrives after the markdown is handed
back and a streamed answer rewrites it on every chunk. The library is the
host's to load: without it the picture is still shown and still downloadable,
and only the enlarging is missing, so a host that has not registered it loses
nothing it had. The opened copy is matched on its own class, since the library
appends it to the body rather than inside the figure -- the widget stylesheet
repeats that one rule unscoped for the same reason.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants