Skip to content

feat(html): let a host put a floor under the margin a view keeps - #748

Merged
andiwand merged 2 commits into
mainfrom
feat-html-min-content-margin
Aug 27, 2026
Merged

feat(html): let a host put a floor under the margin a view keeps#748
andiwand merged 2 commits into
mainfrom
feat-html-min-content-margin

Conversation

@andiwand

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

HtmlConfig::min_content_margin states, per side, the least distance the generated content keeps from the view's border.

Why a floor and not a value

Every view already insets its content by an amount it names itself — 3mm for a reflowed text document, 16px for the page column and the source views, 8px for a font specimen, nothing for an image or a file listing. No single default could stand in for all of them, so the config is a floor: an unset side leaves the view exactly where it is today, a set side only ever raises it.

HtmlConfig config;
config.min_content_margin.left = Measure("12px");   // never closer than 12px
config.min_content_margin.top = Measure("1cm");

How

The arithmetic is css's. Each inset becomes max(own, var(--odr-min-margin-*, 0px)), so the view's own measure stays in the stylesheet and nothing has to be converted to state it:

.odr-text-flow{padding:max(3mm,var(--odr-min-margin-top,0px)) …}
.odr-xml{padding:max(16px,var(--odr-min-margin-top,0px)) …}

write_content_margin_style declares the variables per view and writes nothing at all when no side is set — that is what keeps the shipped stylesheets independent of the config, which they must be: with embed_shipped_resources=false they are shared under one name across documents.

One place still needs the number in pixels: the gutter around the page column is part of the width the view is fitted to, so page_column_gutter_pixels now reads the config. A side css can read but css_pixels cannot convert (em, %) keeps the built-in gutter — the margin still applies, only the fit cannot count it. A unit css could not read at all is dropped rather than written into the <style> element it would otherwise close.

view today with the floor
reflowed text document 3mm max(3mm, …)
page column (text, slides, drawings, pdf) 16px max(16px, …), and the fit follows
plain-text view 16px left on the gutter column, right on the body
xml source view 16px max(16px, …)
file listing, image view none the floor, 0 by default
font specimen 8px max(8px, …)

A sheet and the media view are not inset: the one meets the edge by design (its ruler is sticky against the scrollport), the other is full-bleed.

Bindings

DirectionalStyle<Measure> was already mirrored everywhere, so the surface is small: python min_content_margin, jni minContentMargin (DirectionalMeasure), wasm minContentMargin (css lengths as strings), apple minContentMargin — where ODRMeasure and ODRDirectionalMeasure gain the initialisers a caller needs to state one, having been read-only style mirrors until now.

Verification

  • odr_test: 951 non-output tests and 278 output tests pass; three new html.* tests cover written/not-written, the dropped unit, and the fit.
  • jni minContentMarginReachesTheHtml, a pytest, a wasm node --test case and an XCTest cover each binding.
  • Default config moves no pixel: every reference document renders byte-identical; the six stylesheets that did change (three shipped, three written inline by the image, pdf and specimen views) were compared with compare-html --driver chrome — 410/411 public and 1383/1403 private matched, and every mismatch reproduces when the reference output is compared against a byte-changed copy of itself, i.e. they are screenshot flakes in the pdf and svg views, not this change.

Not in this PR

A margin reference-output variant. compare-html fails on an output directory the reference repo does not have, so pinning the floor visually needs the reference output regenerated and test/data.cmake advanced — say the word and it goes on this branch.

`HtmlConfig::min_content_margin` states, per side, the least distance the
generated content keeps from the view's border. Every view already insets
its content by some amount it names itself — 3mm for a reflowed text
document, 16px for the page column and the source views, 8px for a font
specimen, nothing for an image or a file listing — and no single number
could stand in for all of them, so the config is a floor rather than a
value: an unset side leaves the view exactly where it was, a set one only
ever raises it.

The arithmetic is css's. Each inset becomes `max(own, var(--odr-min-margin-*,
0px))`, so the view's own measure stays in the stylesheet and no unit has to
be converted to state it. `write_content_margin_style` declares the variables
per view and writes nothing at all when no side is set, which is what keeps
the shipped stylesheets independent of the config — they are shared under one
name when the config links rather than embeds them.

One place still needs the number: the gutter around the page column is part
of the width the view is fitted to, so `page_column_gutter_pixels` reads the
config and a side css can read but `css_pixels` cannot convert keeps the
built-in gutter — the margin still applies, only the fit cannot count it. A
unit css could not read at all is dropped rather than written into the style
element it would otherwise close.

A sheet and the media view are not inset: the one meets the edge by design,
the other is full-bleed.

Bound in python, jni, wasm (`minContentMargin`, css lengths as strings) and
apple, where `ODRMeasure` and `ODRDirectionalMeasure` gain the initialisers a
caller needs to state one.

Rendering with the default config is unchanged. Where the stylesheet is linked
the document is byte-identical; where it is embedded — the image, pdf and font
views — the reference output restates the same insets as `max(own, var(...))`,
which with the variables unset is the number it always was, and the shipped
stylesheets render pixel-identically under `compare-html`. The reference-output
pins are advanced to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoWywNazPio5vphzB59U5g
@andiwand
andiwand force-pushed the feat-html-min-content-margin branch from 44cc7c7 to 2c08c53 Compare August 26, 2026 19:15
`is_css_unit` accepted any alphabetic unit, so a value css cannot resolve
was written into `--odr-min-margin-*` and voided the whole shorthand that
floors an inset against it - the opposite of raising it - while the fit
kept counting a gutter the page no longer had.

One predicate now decides both paths: a finite, positive magnitude with a
css length unit, matched case-insensitively. Everything else keeps the
built-in inset. `css_pixels` folds case and drops a non-finite result too,
which makes it a subset again, so the fit calls it directly.

Cover it a level down, in `html_common`, and render every view that writes
the declaration rather than only the odt document view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzAvJtjXwfvy7c5yMxsJpu
@andiwand
andiwand merged commit d13defd into main Aug 27, 2026
35 of 36 checks passed
@andiwand
andiwand deleted the feat-html-min-content-margin branch August 27, 2026 06:36
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.

1 participant