Skip to content

perf(html): stop a sheet repeating itself, and keep a cell's text on one line - #824

Merged
andiwand merged 1 commit into
mainfrom
feat/sheet-html-dedup
Sep 6, 2026
Merged

perf(html): stop a sheet repeating itself, and keep a cell's text on one line#824
andiwand merged 1 commit into
mainfrom
feat/sheet-html-dedup

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Closes #822 and #238 ("handle spreadsheet cell overflow" — html tables and
ods/xlsx differ on overflow, which is what the wrap and spill rules settle).

A rendered sheet spent more than half its bytes on style attributes it wrote
over and over — 26 distinct blocks across 259,957 attributes in
Supervised_Business_Register_300425.ods — and four nodes per cell before any
content. It also broke every cell's text into lines, which no spreadsheet does
unless the file says to, and the second line then painted over the row below.

Everything here is a spreadsheet view. A text document, a presentation and a
drawing emit byte-identical html.

A repeated style block becomes a class

StyleRegistry counts every block a view writes and names the ones written more
than once, <head> defining them once:

<style>
.c1.c1.c1{width:0.889in;min-width:0.889in}
.c4.c4.c4{vertical-align:top;max-width:0;white-space:nowrap;overflow:hidden}
</style>
...
<td class="c4">01/04/2022</td>

The class names itself three times. That is not decoration: an inline style
outranks every rule in our own stylesheets, and .c4 alone would lose
vertical-align to .odr-sheet>tbody>tr>td and a cell border to
.odr-gridlines-soft .odr-sheet td. Three classes buy the specificity back
while staying under !important, which is what the dark sheet relies on.

A block is named the first time it is written. <head> has to name the classes
before the cells that use them and the renderer streams, so a spreadsheet's body
is written into a buffer and the head goes out in front of it — one walk, where
a second would have cost as much as the first. The buffer is capped at 8 MB:
past that it is released early, and a block first seen afterwards stays inline,
which is what an unnamed one would have been anyway. That is the same shape the
pdf view has, where a page model holds the classes until the head is written.

A cell holding one plain string writes no box of its own

td > x-p > x-s > text was four nodes before any content, and the x-s style
mostly repeated the x-p above it. The run always goes; the block goes too
where the file states no row height.

Where it states one, the block stays — it is the only thing that holds the row
to that height. Measured in Chrome: contain: size, contain: strict,
max-height, overflow: hidden on the cell or the row, and
content-visibility are all ignored on internal table elements, and a row's
height is a minimum by spec. Whether a line would have fitted depends on font
metrics the renderer does not have, so guessing was not an option either.

A cell keeps its text on one line

style:wrap-option (ODF), alignment@wrapText (OOXML) and the XF fWrap bit
(XLS) are read into a new TableCellStyle::wrap_text, mirrored in the Python,
JNI and Apple bindings. All three formats default to no wrapping, which is what
Calc and Excel show.

A line too long for its cell then runs over the cells beside it while they hold
nothing, and is cut where the next one has something to show — bounded with
clip-path: inset(0 -Npx 0 0) over the measured width of the blank run, so it
never paints over that content — and out onto the canvas where nothing follows
it at all. A cell the file does wrap keeps its block and clips at the row, so
a wrapped line no longer paints over the row below either.

max-width:0 on the cell is what keeps such a line from widening its column:
table-layout: fixed still sizes the table from content, and without it one
long string took a column from 85 px to 3139 px. It is emitted only where the
file states a column width, since a column that states none is exactly its
content's width.

odr-public/ods/overflow.ods, before and after — the file that documents this:

before rows 1-5 an unreadable pile of overlapping text
after one line each; row 3 cut at B's edge, row 4 spilling over the empty C and cut before D's content

Clicking a cut cell raises it

The block goes out of flow, sized to the string and painted over its neighbours,
so no row moves. A click inside it is for the text — selecting it, and one day
editing it — not for the cell; a click anywhere else, or Escape, puts it back.
A cell that writes its string without a block gets a wrapper from the script for
as long as it is up.

Numbers

Read-only, the config the Android viewer renders with:

before after
Supervised_Business_Register_300425.ods, 500,000 cells 121.4 MB 37.7 MB
— inline style attributes 1,547,585 0
— elements in the table 1.5 M 1.0 M
the whole spreadsheet corpus, 314 views 374 MB 202 MB
render time, register sheet 4.9 s 4.9 s
peak RSS, register sheet 475 MB 493 MB

Verification

  • compare-html --driver chrome over all 314 ods/xlsx/xls/csv/numbers views of
    the public and private corpora: 254 identical, 60 different — every one of
    the 60 a sheet that used to wrap. An earlier run of the dedup and the run fold
    alone was 298/298 identical, in both the read-only and the editable config.
  • Full odr_test: 1452 passed, 6 pre-existing skips.
  • Buffering the body rather than walking twice moved nothing: 314 of 314
    identical against the two-pass version it replaces.
  • Ten tests in html_test.cpp off an inline flat-ODS string, and
    odr-public/ods/file_example_ODS_100.ods pinned to the read-only
    reference-output variant.
  • test/browser/sheet/ — 14 checks for the raise behaviour, in the repo's
    existing idiom for script-only behaviour (serve lifts the css and js out of
    frontend.cpp, so what runs is what ships). All pass in Chrome.
  • clang-tidy on the touched translation units reports nothing new.
  • DeferredBuffer lives in util/stream_util with three unit tests of its own.

Reference output

Regenerated: 361 files, every one a spreadsheet view, plus
resources/spreadsheet*.{css,js} — the last of which also catches up with
pre-existing drift from #817 and #820. Not pushed yet, so test/data.cmake
still points at the old revisions.

@andiwand
andiwand force-pushed the feat/sheet-html-dedup branch from 628553d to 147efb1 Compare September 6, 2026 09:09
@andiwand andiwand changed the title perf(html): stop a sheet repeating the same style a quarter-million times perf(html): stop a sheet repeating itself, and keep a cell's text on one line Sep 6, 2026
@andiwand
andiwand force-pushed the feat/sheet-html-dedup branch 4 times, most recently from f4728f1 to fbe23cc Compare September 6, 2026 10:23
…one line

A rendered sheet spent more than half its bytes on `style` attributes it
wrote over and over — 26 distinct blocks across 259,957 attributes in
`Supervised_Business_Register_300425.ods` — and four nodes per cell before
any content. It also broke every cell's text into lines, which no
spreadsheet does unless the file says to, and the second line then painted
over the row below.

- A style block becomes a class defined once in `<head>`, named the first
  time it is written. The class names itself three times, for the
  specificity an inline attribute had. `<head>` has to name the classes
  before the cells that use them and the renderer streams, so a
  spreadsheet's body goes into a buffer and the head out in front of it —
  one walk, where a second would cost as much as the first. The buffer is
  capped at 8 MB, past which a block first seen stays inline.

- A cell holding one plain string drops the run around it, and where the
  file states no row height the block too. A stated row height keeps the
  block: nothing else holds the row to it, since `contain:size`,
  `max-height`, `overflow` and `content-visibility` are all ignored on a
  table cell.

- `style:wrap-option`, `alignment@wrapText` and the XF `fWrap` bit are read
  into a new `TableCellStyle::wrap_text`, off by default in every format. A
  line too long for its cell runs over the empty cells beside it and is cut
  where the next one has content — bounded with `clip-path`, so it never
  paints over it — and out onto the canvas where nothing follows.
  `max-width:0` keeps such a line from widening the column, which
  `table-layout:fixed` alone does not.

- Clicking a cell that is cut raises it: the block goes out of flow, sized
  to the string and over its neighbours, so no row moves. Clicking inside
  it is for the text; clicking elsewhere, or Escape, puts it back.

Read-only, the reference corpus's sheets fall from 374 MB to 202 MB, and
the 500,000 rendered cells of the register from 121 MB to 38 MB — 1.5 M
inline styles down to none. 254 of 314 spreadsheet views render
pixel-identically; the 60 that move are the ones that used to wrap.
Rendering takes as long as it did, and peaks about 4% higher.

Closes #822. Closes #238.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUh7UU43PbVPEBq2LVtoVN
@andiwand
andiwand force-pushed the feat/sheet-html-dedup branch from fbe23cc to 6f2dadd Compare September 6, 2026 10:36
@andiwand
andiwand merged commit ee25813 into main Sep 6, 2026
35 of 36 checks passed
@andiwand
andiwand deleted the feat/sheet-html-dedup branch September 6, 2026 11:02
andiwand added a commit that referenced this pull request Sep 6, 2026
Covers the regeneration #824 needed and the pdf rename this branch makes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUh7UU43PbVPEBq2LVtoVN
andiwand added a commit that referenced this pull request Sep 6, 2026
#829)

* refactor(html): share one style writer between the sheet and pdf views

`AtomicStyles` in `pdf_file.cpp` and `StyleRegistry` were the same class
written twice: a declaration→class map, a first-seen order, and a rule
writer. One class now serves both, with the two things that actually differ
as parameters.

- `Rank`: whether a rule has to outrank the stylesheets around it. A sheet's
  classes stand in for inline styles and name themselves three times for the
  specificity they took over; a pdf page's compete with nothing.
- `Digits`: base 36 names the first 36 of a prefix in one character, but
  only where no prefix extends another. The sheet view uses one prefix and
  takes it; the pdf view keeps decimal, because `w` at 1008 spells `ws0`,
  which is also `ws` at 0 — and because `strip_width_class` reads a width
  class back out of the class string as `w` and digits.

The pdf views' class names are numbered from 0 rather than 1. The rules are
the same set, renamed: 802 pdf views render identically, and the html is
0.47% smaller. Nothing else moves — every sheet view is byte-identical.

* test(data): advance the reference-output pins

Covers the regeneration #824 needed and the pdf rename this branch makes.
---------

Co-authored-by: Claude Opus 5 (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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A sheet repeats 26 style strings a quarter-million times, and the browser pays 10-20 KB a cell

1 participant