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');
+ });
+});