From 9bfd579d659148c47da2bdb6da5e4e44eb36052c Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 19:49:33 +0000 Subject: [PATCH] fix: hidden text stays off the page, except the heading a running head reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text:display="none" now drops the paragraph its style hides and the stored value of a field that carries the attribute — LibreOffice lays out neither. A hidden heading is the exception and still renders: it is the outline the running head's chapter field reads, and dropping it blanked every head that quotes one. The editor has no way to hold a block it does not draw. --- src/lib/import/CLAUDE.md | 1 + src/lib/import/odt.ts | 15 +++++++++- tests/unit/hidden-text.test.ts | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/unit/hidden-text.test.ts diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index c419b15..da95c8d 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -16,6 +16,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( the export use it) and suppressed against the block's own style, so a 100% under a 115% style stays as direct formatting. - **A complex-script run takes the `-complex` font and size** (`complexScriptProps`). ODF carries western, asian and complex properties side by side and a word processor picks per character, so a Hebrew paragraph whose style declares only `style:font-size-complex="16pt"` is set at 16pt — read as the western 12pt its line pitch comes out 1.6mm short of LibreOffice's. Decided per **run**, the finest split the model has, so a document with no complex script is untouched; our own export writes all three alike, so the value round-trips. +- **Hidden text is not laid out.** `text:display="none"` drops the paragraph a paragraph style hides (with a warning) and the stored value of a field element that carries the attribute. A hidden **heading** is the exception and renders: it is the document's outline, which the running head's `text:chapter` reads (an ODF chapter marker exists for nothing else), and the editor has no way to hold a block it does not draw. - **A run is measured against the block's own size**, not the yardstick's: a list item whose style sets 11pt would otherwise drop every 12pt run as "the style supplies it" and render it at 11 (`convertParaLike`, `runDefaults`). - **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour). - **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 10ee209..01fbe50 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -833,6 +833,16 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o flush(); } +// A style that hides its paragraph takes the line with it, not just the text. A hidden +// *heading* stays: it is the document's outline, which the running head's chapter field +// reads — an ODF chapter marker exists for nothing else, and dropping it blanks the head. +function hiddenParagraph(el: Element, ctx: Ctx): boolean { + if (el.localName !== 'p') return false; + if (ctx.resolver.paraTextProps(el.getAttributeNS(NS.text, 'style-name'))['text:display'] !== 'none') return false; + ctx.warnings.add('Hidden text was removed'); + return true; +} + function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] { const out: Node[] = []; @@ -881,6 +891,7 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef for (const el of elements) { if (el.namespaceURI === NS.text) { if (el.localName === 'p' || el.localName === 'h') { + if (hiddenParagraph(el, ctx)) continue; pushWithPending(hoistPageFrames(convertParaLike(el, ctx, kind, boldByDefault))); } else if (el.localName === 'list') { // A list wrapping only headings is ODF outline (chapter) numbering, not a real @@ -1553,6 +1564,8 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl const e = child as Element; if (e.namespaceURI === NS.text) { + // A field set to show nothing shows nothing: its stored value is not text. + if (e.getAttributeNS(NS.text, 'display') === 'none') continue; switch (e.localName) { case 'span': { const spanStyle = e.getAttributeNS(NS.text, 'style-name'); @@ -1739,7 +1752,7 @@ function capsFromOdf(props: PropMap): CapsMode | null { // complex-script fonts side by side, and text in a complex script is set from the // -complex ones. A file that leaves them at the defaults sets Hebrew at 12pt Times // where LibreOffice sets it at the 16pt the style really declares. -const COMPLEX_SCRIPT_RE = /[֐-ࣿऀ-෿฀-๿ក-៿יִ-﷿ﹰ-ﻼ]/; +const COMPLEX_SCRIPT_RE = /[֐-ࣿऀ-෿฀-๿ក-៿יִ-﷿ﹰ-ﻼ]/; const COMPLEX_ALIASES = [ ['fo:font-size', 'style:font-size-complex'], diff --git a/tests/unit/hidden-text.test.ts b/tests/unit/hidden-text.test.ts new file mode 100644 index 0000000..03fa623 --- /dev/null +++ b/tests/unit/hidden-text.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; + +// text:display="none" — on a style it hides the paragraph, on a field it hides the +// stored value. A hidden heading is the exception: it carries the outline the running +// head reads, and the editor can hold it only by rendering it. +const NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' + + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' + + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' + + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'; + +const styles = ` + + + + + + + + `; + +function odt(body: string): Uint8Array { + const content = ` + ${body}`; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const blocks = (body: string) => importOdt(odt(body)).content.content ?? []; +const textOf = (n: { content?: { text?: string }[] }) => (n.content ?? []).map((c) => c.text ?? '').join(''); + +describe('hidden text on ODF import', () => { + it('drops the whole paragraph a style hides, not just its text', () => { + const out = blocks('HiddenVisible'); + expect(out.map(textOf)).toEqual(['Visible']); + }); + + it('keeps a hidden heading, which the running head reads as the chapter', () => { + const out = blocks('Chapter 1' + + 'Visible'); + expect(out.map(textOf)).toEqual(['Chapter 1', 'Visible']); + }); + + it('renders nothing for a field the file marks as not displayed', () => { + const out = blocks('a42b'); + expect(out.map(textOf)).toEqual(['ab']); + }); + + it('says so, rather than dropping content silently', () => { + expect(importOdt(odt('x')).warnings) + .toContain('Hidden text was removed'); + }); +});