Skip to content

fix(export): let the exported HTML follow the preview's max width - #468

Merged
PathGao merged 1 commit into
masterfrom
fix/export-html-width-follows-setting
Aug 6, 2026
Merged

fix(export): let the exported HTML follow the preview's max width#468
PathGao merged 1 commit into
masterfrom
fix/export-html-width-follows-setting

Conversation

@PathGao

@PathGao PathGao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #467.

@Haiulus asked whether the width of the exported HTML can be changed. Today it cannot: buildExportDocument writes .markdown-body { max-width: 900px } into every file the export produces.

What 900 was

Nothing, as far as the rest of the app is concerned. Preview → Max width has existed since #346#349 (renamed to Max width in #456): bounded 640–1600, default 880, persisted, with widen/narrow shortcuts and a modified-row indicator in Settings. The export ignored it and shipped 900 — not the user's choice, and not even the app's own default. That reads like an oversight rather than a decision, which is why this is a fix and not a feature.

What changed

exportAsHtml in MarkdownViewer.svelte already had the value in hand: previewContentWidth, i.e. getPreviewContentWidth(settings.previewMaxWidth, isFullWidth) — the same derived value that becomes --preview-max-width on the article the user is looking at. It is now one more field on the export context, and buildExportDocument interpolates it where the constant used to be. One source of truth; what leaves the app is the measure it was read at.

Full width → max-width: none. getPreviewContentWidth returns null for full-width mode. In a standalone file that means "no cap". The preview spells the same thing 100%, and for this element the two are equivalent — .markdown-body is a block child of <body>, so a percentage cap resolves to the width it already has — but none states the intention without depending on that reasoning and cannot interact with box-sizing. Confirmed on a real exported file in a browser: the column follows the window, margin: 0 auto simply has no slack left to distribute, and padding: 40px !important still holds the text off the window edge.

Normalized at the seam. The value goes through normalizePreviewMaxWidth inside exportContentMaxWidth, because that is where it stops being a number and becomes a CSS declaration. previewMaxWidth is restored from localStorage; a corrupted or hand-edited key must not be able to write its own rules into the exported stylesheet. Out of range clamps, unparseable degrades to the default — exactly as the preview does.

Nothing else used the 900. The only other occurrences in the repo are unrelated (a scrollTop fixture, an out-of-range font size in a persistence test, a stroke-dashoffset inside a captured Mermaid SVG).

The print/PDF route is untouched, and now provably so. @media print in styles.css already forces .markdown-body to max-width: 100% !important with its own .75in padding, and that block travels into the export with the rest of the sheet. Printing an exported file therefore never saw the 900 and does not see the setting either. There is a test for it, so the two routes staying separate concerns is pinned rather than assumed.

The design call — follow the setting, or add a second one?

This is a real question and I would rather put it to you than pretend it is not.

For following the preview width (what this PR does): no new UI, no new i18n keys, one source of truth, and what you export matches what you were reading.

For a separate export-only width: an exported file is read on someone else's screen. An author's editing measure is not necessarily their publishing measure, and someone who reads narrow but publishes wide is not being unreasonable.

The reason I came down on "follow" anyway is that the escape hatch is already excellent. The export is a self-contained single HTML file with its <style> inline — anyone who wants a different width edits one line in the file they just produced. Adding a second width control to Settings, with its own label, its own translations, its own modified-row logic and its own reset, for something that is a one-line edit downstream, is not a good trade against the existing surface.

If you would rather have the separate setting, say so and I will add it — the plumbing here is the same either way, it just reads from a different field.

Workaround for @Haiulus in the meantime: open the exported .html, find .markdown-body { … max-width: … } in the <style> block near the top, and change that one value. The stylesheet is inline, so nothing else has to be touched and the file stays self-contained.

Tests

scripts/exportContentWidth.test.ts (9 tests). They resolve the stylesheet the export actually emits rather than matching the template that emits it — a declaration only means something once it is inside the <style> block, attached to a selector the shipped <article class="markdown-body"> matches, and last in the cascade.

  • the exported cap is the configured one, at both bounds, the default and two values in between
  • changing the setting changes the file, and the emitted cap matches getPreviewContentWidth for every combination of stored value and full-width state
  • full width emits none, and the padding and centring survive it
  • a corrupted stored value (99999, -40, '1e9', '900px; } body { display: none; } .x {', NaN, Infinity) is clamped or defaulted, never interpolated raw — asserted both as a table and as a shape, so a future value that is neither a clamped pixel count nor none cannot pass by looking plausible
  • the export's cap loses to the @media print block, so the PDF route is unchanged
  • the app's copied stylesheet does not out-rank the export's own rule

Two of the nine run the real exportAsHtml end to end, on the harness exportRichContent.test.ts established (Tauri boundary and the rich-content libraries stubbed, everything in between running), and read the bytes handed to save_file_content. One of them plants a decoy .markdown-body { max-width: 900px } in the copied stylesheet, so the run answers "does the export's cap win the cascade" and not merely "is the number in the file".

The one link that genuinely cannot be executed under the Node runner is the call site itself — it is in a .svelte component, which the runner cannot import (scripts/sourceTree.ts explains why). That one is checked against the source of that single function via functionSource, not a whole-file match, so a contentWidth: appearing anywhere else in a 3700-line component cannot satisfy it.

Falsification. Reverting the template to max-width: 900px and keeping the tests turns 7 of the 9 red, naming the actual problem:

✖ the exported file is capped at the width the preview was reading at
  AssertionError: an export made at 640 must cap at 640
  '900px' !== '640px'

✖ a real export writes a file capped at the configured width
  AssertionError: an export made at 640 must be readable at 640
  '900px' !== '640px'

✖ a full-width export is uncapped, still padded and still centred
  '900px' !== 'none'

ℹ tests 9 | pass 2 | fail 7

The two that stay green under that revert are the print-block test (correct — that path is unaffected) and the call-site test (correct — the wiring was not what was reverted). Reverting only the MarkdownViewer.svelte wiring instead inverts it exactly: 8 pass, and the call-site test alone fails.

Verification

npm audit (0 vulnerabilities), npm run check (645 files, 0 errors), npm test (692 pass), and cargo test in src-tauri/ (157 pass) — what .github/workflows/test.yml runs.

Beyond that, I drove the real exportAsHtml with the real src/styles.css to produce four actual export files (640, 880, 1600, full width) and opened them in a browser at a 1440px viewport. Measured: the 640 file lays out at 720px wide (640 + 2×40 padding), centred at x=360; the 880 file at 960px, centred at x=240; the full-width file fills the viewport with no horizontal overflow, and reflows to 700px when the window is narrowed to 700. Screenshot confirms the 640 file renders centred and correctly measured.

I did not export from a running Tauri build — the export path was driven directly rather than through the app's file dialog.

`buildExportDocument` wrote `.markdown-body { max-width: 900px }` into
every file the HTML export produced. 900 is not a number the app has
anywhere else: Preview -> Max width (#346-#349, renamed to "Max width" in
#456) is bounded 640-1600 and defaults to 880. So a document read at 640
and a document read at 1600 both left the app at 900, and the one setting
that governs how wide the same document renders everywhere else stopped
at the file dialog. #467 asks for the width to be configurable; it
already is, the export just was not listening.

`exportAsHtml` in MarkdownViewer.svelte now hands the export the value it
already derives for the live preview -- `getPreviewContentWidth(
settings.previewMaxWidth, isFullWidth)`, the same thing that becomes
`--preview-max-width` on the article the user is looking at -- and
`buildExportDocument` interpolates it in place of the constant. One
source of truth, and what leaves the app is the measure it was read at.

Full-width mode is that function's `null`, and it becomes `max-width:
none` rather than the preview's `100%`. The two are equivalent for this
element (`.markdown-body` is a block child of `<body>`, so a percentage
cap resolves to the width it already has), but `none` states the
intention without depending on that reasoning and cannot interact with
`box-sizing`. Checked in a browser on a real exported file: the column
follows the window, `margin: 0 auto` has no slack left to distribute,
and the `padding: 40px !important` still holds the text off the edge.

The number is normalized through `normalizePreviewMaxWidth` at the point
where it stops being a number and becomes a CSS declaration.
`previewMaxWidth` is restored from localStorage, and a corrupted or
hand-edited key must not be able to interpolate itself raw into the
exported stylesheet; an unusable value degrades to the app default the
same way the preview's does.

The print/PDF route is untouched and stays untouched: `@media print` in
styles.css already forces `.markdown-body` to `max-width: 100%
!important` with its own .75in padding, so printing an exported file
never saw the 900 and does not see the setting either. There is a test
for that, because the two routes staying separate is now a property
worth pinning rather than an accident.

scripts/exportContentWidth.test.ts resolves the stylesheet the export
actually emits instead of matching the template that emits it: a
declaration only means something once it is in the `<style>` block, on a
selector the shipped `<article class="markdown-body">` matches, and last
in the cascade. Two of the tests run the real `exportAsHtml` end to end
on the harness exportRichContent.test.ts established and read the bytes
handed to `save_file_content`, with a decoy `max-width: 900px` planted in
the copied stylesheet so the run also answers "does the export's cap win"
and not merely "is it in the file". The one link that cannot be executed
under the Node runner is the call site itself, since it lives in a
`.svelte` component; that is checked against the source of that single
function via `functionSource`.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit afe2059 into master Aug 6, 2026
4 checks passed
PathGao added a commit that referenced this pull request Aug 6, 2026
The review discipline here is real but undocumented: a mechanism section that
explains why the old behaviour happened rather than what was done about it, a
Scope section saying what was deliberately left alone, a falsification step on
fixes, and a Verification section with the exact commands, their counts and an
honest list of what was not checked. #468, #464, #462, #460 and #458 all have
that shape. Nobody arriving from outside can know it. #463 came close by
instinct, which is the argument for writing it down rather than hoping.

Five headings, prompts only, no checkboxes. A checkbox that feels mandatory is
a required field wearing a disguise, and friction is what makes a contributor
abandon a template rather than fill it in; the header says outright that every
section can be deleted. No licensing, conduct or "I read the guide" line --
there is no contributing guide to read.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao deleted the fix/export-html-width-follows-setting branch August 6, 2026 02:39
@PathGao PathGao mentioned this pull request Aug 6, 2026
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.

[QUESTION] Width of exported HTML

1 participant