fix: hidden text stays off the page, except the heading a running head reads

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.
This commit is contained in:
Steffen Becker committed 2026-08-12 19:49:33 +00:00
1 parent 99a14a1a7f
commit 9bfd579d65
3 files changed
+69 -1

No files matched your search

+1
View File
@@ -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.
+14 -1
View File
@@ -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'],
+54
View File
@@ -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 = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
<style:default-style style:family="paragraph">
<style:text-properties fo:font-size="12pt" fo:font-family="Liberation Serif"/>
</style:default-style>
<style:style style:name="Standard" style:family="paragraph"/>
<style:style style:name="Marker" style:family="paragraph" style:parent-style-name="Standard">
<style:text-properties text:display="none"/>
</style:style>
</office:styles></office:document-styles>`;
function odt(body: string): Uint8Array {
const content = `<?xml version="1.0"?><office:document-content ${NS}>
<office:body><office:text>${body}</office:text></office:body></office:document-content>`;
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('<text:p text:style-name="Marker">Hidden</text:p><text:p>Visible</text:p>');
expect(out.map(textOf)).toEqual(['Visible']);
});
it('keeps a hidden heading, which the running head reads as the chapter', () => {
const out = blocks('<text:h text:style-name="Marker" text:outline-level="1">Chapter 1</text:h>'
+ '<text:p>Visible</text:p>');
expect(out.map(textOf)).toEqual(['Chapter 1', 'Visible']);
});
it('renders nothing for a field the file marks as not displayed', () => {
const out = blocks('<text:p>a<text:user-field-get text:display="none" text:name="M">42</text:user-field-get>b</text:p>');
expect(out.map(textOf)).toEqual(['ab']);
});
it('says so, rather than dropping content silently', () => {
expect(importOdt(odt('<text:p text:style-name="Marker">x</text:p>')).warnings)
.toContain('Hidden text was removed');
});
});