diff --git a/CHANGELOG.md b/CHANGELOG.md index 8322582..8f154fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ - Font family picker (lists only fonts installed on the machine; remembers recently used fonts) - Font size - Font color and highlight color (color picker with custom colors) -- Headings H1–H6 with LibreOffice's sizes (18/16/14/13/12/12 pt, levels 4 and 6 italic), including un-bolding a heading +- Headings H1–H10 with LibreOffice's sizes (18/16/14/13/12/12 pt, levels 4 and 6 italic), including un-bolding a heading. Levels 7–10 continue level 6 at 12pt — probed, LibreOffice writes those styles with no properties of their own and resolves them from its style pool, so there is no file value to follow. HTML stops at `h6`, so they render as unknown elements the editor's CSS makes blocks **Styles** - Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–6 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12/12 pt headings) @@ -152,7 +152,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. - One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments - Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither - Restarting the page numbering **per section** (only the document's own format and start value exist) -- Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`) - A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name - A section's own **page format** is set only by the file it came from — the ribbon offers the orientation per section, the paper size document-wide. Pages of different widths are left-aligned in the scroll, where both word processors centre each - A sort dialog's second and third key, and its choice of sort type over the detected one; a formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 1ee7f05..207df95 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -8,7 +8,11 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`. - **Headings** follow LibreOffice too (`HEADING_STYLE_OVERRIDES`): 18 / 16 / 14 / 13 / 12 / 12pt (levels 4 and 6 italic) with the Heading style's 0.423cm/0.212cm margins on every level, bold, and **sans** (`HEADING_FONT` = Arial; on screen the bundled `@font-face` maps it to Liberation Sans, metric-identical, mirroring Liberation Serif → - Times New Roman for the body). Levels 1–6 (`MAX_HEADING_LEVEL` = `HEADING_STYLE_OVERRIDES.length`); + Times New Roman for the body). Levels **7–10** continue level 6 at 12pt (alternating italic): + probed, LibreOffice writes those styles with no properties at all and resolves them from its + own pool, so there is no file value to follow. HTML stops at `h6`, so 7–10 render as unknown + elements that `editor.css` gives `display: block`. + Levels 1–10 (`MAX_HEADING_LEVEL` = `HEADING_STYLE_OVERRIDES.length`); both constants live in `export/odt.ts` and feed the importers, the TOC, the DOCX heading styles, `utils/fontSize.ts`, and `editor.css` (kept in sync by `tests/unit/font-size-display.test.ts`). The importers no longer override a file's own heading formatting: they only suppress values diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 71892e6..ab942ad 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -224,6 +224,12 @@ export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTo { name: 'Heading_20_4', fontSize: '13pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true }, { name: 'Heading_20_5', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, { name: 'Heading_20_6', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true }, + // 7–10 continue level 6 rather than adding a step of our own: probed, LibreOffice + // writes these styles with no properties at all and resolves them from its own pool. + { name: 'Heading_20_7', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_8', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true }, + { name: 'Heading_20_9', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_10', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true }, ]; // Headings are sans (LibreOffice's Heading style). On screen the bundled 'Arial' diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 2894da6..c5e6651 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -765,7 +765,7 @@ function styleIdOf(ppr: Element | null, ctx: Ctx): string | null { // LibreOffice writes styleId "Standard" with the name "Normal"). function registryName(id: string, wordName: string, isDefault: boolean): string { if (isDefault || id === 'Normal' || /^normal$/i.test(wordName)) return DEFAULT_STYLE; - const heading = /^Heading\s?([1-9])$/i.exec(id) ?? /^heading\s?([1-9])$/i.exec(wordName); + const heading = /^Heading\s?(10|[1-9])$/i.exec(id) ?? /^heading\s?(10|[1-9])$/i.exec(wordName); if (heading) return `Heading ${heading[1]}`; if (/^Title$/i.test(id)) return 'Title'; if (/^Subtitle$/i.test(id)) return 'Subtitle'; @@ -894,7 +894,7 @@ function collectStyleSheet(ctx: Ctx): StyleSheet { para: ownProps(stylePara(ctx, id), stylePara(ctx, parentId)), text: ownProps(styleText(ctx, id), styleText(ctx, parentId)), }; - const level = /^Heading (\d)$/.exec(name); + const level = /^Heading (\d+)$/.exec(name); if (level) style.outlineLevel = Number(level[1]); sheet.paragraph[name] = style; } @@ -1029,7 +1029,7 @@ function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null { const ps = fc(ppr, 'pStyle'); const id = ps ? wVal(ps) : null; if (id) { - const m = /^Heading\s?([1-9])$/i.exec(id); + const m = /^Heading\s?(10|[1-9])$/i.exec(id); if (m) return clamp(parseInt(m[1], 10)); const ol = ctx.styles.styleOutlineLvl(id); if (ol != null && ol >= 0 && ol <= 8) return clamp(ol + 1); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 5cb7029..f9cde87 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1366,7 +1366,7 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet { para: ownProps(paraPropsFromOdf(resolver.paraProps(odfName)), paraPropsFromOdf(def.parent ? resolver.paraProps(def.parent) : {})), text: ownProps(textPropsFromOdf(resolver.paraTextProps(odfName), resolver), textPropsFromOdf(def.parent ? resolver.paraTextProps(def.parent) : {}, resolver)), }; - const level = /^Heading (\d)$/.exec(name); + const level = /^Heading (\d+)$/.exec(name); if (level) style.outlineLevel = Number(level[1]); sheet.paragraph[name] = style; } diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 0a84fc3..fd27395 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -70,8 +70,9 @@ const BUILTINS: Style[] = [ para: { spaceBefore: 0, spaceAfter: 0 }, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } }, { name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true, para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Liberation Sans', bold: true } }, - // Sizes and the italic on 4 and 6 are LibreOffice's; the margins come from the Heading parent. - ...([[18], [16], [14], [13, true], [12], [12, true]] as [number, boolean?][]).map(([size, italic], i) => ({ + // Sizes and the italic on 4 and 6 are LibreOffice's; the margins come from the Heading + // parent. 7–10 continue level 6 (HEADING_STYLE_OVERRIDES says why). + ...([[18], [16], [14], [13, true], [12], [12, true], [12], [12, true], [12], [12, true]] as [number, boolean?][]).map(([size, italic], i) => ({ name: `Heading ${i + 1}`, parent: HEADING_PARENT, next: DEFAULT_STYLE, outlineLevel: i + 1, builtin: true, para: {}, text: italic ? { fontSizePt: size, italic: true } : { fontSizePt: size }, @@ -97,7 +98,7 @@ const CHAR_BUILTINS: Style[] = [ // Bumped whenever the built-in definitions change: a stored sheet from an older version // keeps its user styles but takes the new factory built-ins (see mergeStoredSheet). -export const STYLE_SHEET_VERSION = 9; +export const STYLE_SHEET_VERSION = 10; // A persisted sheet merged onto the current built-ins. Same version: stored entries win // (a document's own styles, and edits to built-ins). Older: only user styles survive. diff --git a/src/styles/editor.css b/src/styles/editor.css index f1108bf..58c0544 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -576,14 +576,11 @@ overflow-wrap: break-word; } -.paper .tiptap h1, -.paper .tiptap h2, -.paper .tiptap h3, -.paper .tiptap h4, -.paper .tiptap h5, -.paper .tiptap h6 { +.paper .tiptap :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { /* Neutral base: font, size and margins come from the named paragraph styles - (styles/styleSheet.ts → the #document-styles element Editor.svelte writes). */ + (styles/styleSheet.ts → the #document-styles element Editor.svelte writes). + HTML stops at h6, so 7–10 are unknown elements — inline until told otherwise. */ + display: block; font: inherit; margin: 0; } @@ -596,10 +593,10 @@ inherits: false; initial-value: 0; } -.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, .toc, .toc-title, .toc-entry) { +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, .toc, .toc-title, .toc-entry) { padding-top: var(--space-before); } -.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, h6, .toc, .toc-title, .toc-entry) { +.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, .toc, .toc-title, .toc-entry) { padding-top: 0; margin-top: var(--space-before); } @@ -613,14 +610,14 @@ /* The line box: the paragraph's spacing factor times its font's natural line height. Either half may come from the style rule or, nearer, from the block's own attrs (lineHeight.ts / blockFontSize.ts) — multiplying them here is what keeps both. */ -.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, li, .toc-entry) { +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, li, .toc-entry) { line-height: calc(var(--line-factor) * var(--natural-line)); } /* A spacing above single puts all of its extra leading *below* each line (measured in LibreOffice); CSS splits it half above. Shifting the block up by that half draws its lines where LibreOffice does and leaves the flow — so pagination — untouched. */ -.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6) { +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { position: relative; top: calc((1 - var(--line-factor)) * var(--natural-line) * 0.5em); } @@ -647,11 +644,11 @@ /* Out of flow, so the marker cannot touch the line it labels — in flow it grew every list line by a pixel, which cost the thesis fixture a page. `top: 0` seats it on the first line, since the pseudo takes the block's own line-height. */ -.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6) { +.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { position: relative; } -.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { position: absolute; top: 0; left: -0.635cm; @@ -668,7 +665,7 @@ /* A label set against the far end of the hanging indent grows into the margin instead of into the text — what keeps Word's Roman numerals off the words they label. */ -.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { left: auto; right: calc(100% + 0.635cm); } @@ -676,21 +673,21 @@ /* Ordered numbering, keyed by data-eff-list-style — the effective numbering orderedList.ts's plugin resolves per
    from its attr + ancestor chain. The undecorated
      is the plain decimal fallback and must come first. */ -.paper .tiptap ol > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ". "; } -.paper .tiptap ol[data-eff-list-style="decimal"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ". "; } -.paper .tiptap ol[data-eff-list-style="decimal-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ") "; } -.paper .tiptap ol[data-eff-list-style="lower-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-alpha) ". "; } -.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-alpha) ") "; } -.paper .tiptap ol[data-eff-list-style="upper-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-alpha) ". "; } -.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-alpha) ") "; } -.paper .tiptap ol[data-eff-list-style="lower-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-roman) ". "; } -.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-roman) ") "; } -.paper .tiptap ol[data-eff-list-style="upper-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-roman) ". "; } -.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-roman) ") "; } +.paper .tiptap ol > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, decimal) ". "; } +.paper .tiptap ol[data-eff-list-style="decimal"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, decimal) ". "; } +.paper .tiptap ol[data-eff-list-style="decimal-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, decimal) ") "; } +.paper .tiptap ol[data-eff-list-style="lower-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, lower-alpha) ". "; } +.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, lower-alpha) ") "; } +.paper .tiptap ol[data-eff-list-style="upper-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, upper-alpha) ". "; } +.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, upper-alpha) ") "; } +.paper .tiptap ol[data-eff-list-style="lower-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, lower-roman) ". "; } +.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, lower-roman) ") "; } +.paper .tiptap ol[data-eff-list-style="upper-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, upper-roman) ". "; } +.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counter(list-item, upper-roman) ") "; } /* Legal/outline numbering (1., 1.1., 1.2.1. …): the plugin tags the multilevel list and every attr-less member of its chain with data-eff-list-style="multilevel". */ -.paper .tiptap ol[data-eff-list-style="multilevel"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper .tiptap ol[data-eff-list-style="multilevel"] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: counters(list-item, ".") ". "; } @@ -698,25 +695,25 @@ (DEFAULT_BULLET_CYCLE in bulletListTypes.ts). The bundled symbol shim comes first: it holds only the glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those stay compact and platform-independent while every other char falls through to the text font. */ -.paper .tiptap ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper .tiptap ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '• '; font-family: 'EdenText Symbols', var(--marker-family, var(--font-serif)), serif; } -.paper .tiptap ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '◦ '; } -.paper .tiptap ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '▪ '; } -.paper .tiptap ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '▸ '; } -.paper .tiptap ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '– '; } -.paper .tiptap ul ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '· '; } +.paper .tiptap ul ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '◦ '; } +.paper .tiptap ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '▪ '; } +.paper .tiptap ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '▸ '; } +.paper .tiptap ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '– '; } +.paper .tiptap ul ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: '· '; } /* A list's own `bulletChar` beats the cycle at any depth — an attribute outranks the descendant chain. A nested list without one keeps its depth's char, which is what both exports write, even though --bullet inherits into it. */ -.paper .tiptap ul[data-bullet] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper .tiptap ul[data-bullet] > li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: var(--bullet) ' '; } /* The PDF clone (the .paper itself) replaces every marker with a real span (pdf.ts). */ -.paper.pdf-list-markers li > :is(p, h1, h2, h3, h4, h5, h6)::before { +.paper.pdf-list-markers li > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::before { content: none; } @@ -874,13 +871,7 @@ /* Paragraph mark (¶). Out of flow at its static position — after the last character, but invisible to line breaking, so a line-end space still hangs into the margin and the mark can never be pushed onto a line of its own. The toggle only recolors it. */ -.paper .tiptap p::after, -.paper .tiptap h1::after, -.paper .tiptap h2::after, -.paper .tiptap h3::after, -.paper .tiptap h4::after, -.paper .tiptap h5::after, -.paper .tiptap h6::after { +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::after { content: '\00A0¶'; position: absolute; display: inline-block; @@ -899,13 +890,7 @@ pointer-events: none; } -.paper.show-formatting-marks .tiptap p::after, -.paper.show-formatting-marks .tiptap h1::after, -.paper.show-formatting-marks .tiptap h2::after, -.paper.show-formatting-marks .tiptap h3::after, -.paper.show-formatting-marks .tiptap h4::after, -.paper.show-formatting-marks .tiptap h5::after, -.paper.show-formatting-marks .tiptap h6::after { +.paper.show-formatting-marks .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::after { color: var(--color-primary); } @@ -918,24 +903,12 @@ /* A block ProseMirror ends with a trailing-break BR — an empty one, or one ending in a hard break — has its last line before that BR, not after it. */ -.paper .tiptap p:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h1:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h2:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h3:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h4:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h5:has(> br.ProseMirror-trailingBreak), -.paper .tiptap h6:has(> br.ProseMirror-trailingBreak), +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10):has(> br.ProseMirror-trailingBreak), .paper .tiptap li > p:has(> br.ProseMirror-trailingBreak) { position: relative; } -.paper .tiptap p:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h1:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h2:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h3:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h4:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h5:has(> br.ProseMirror-trailingBreak)::after, -.paper .tiptap h6:has(> br.ProseMirror-trailingBreak)::after, +.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10):has(> br.ProseMirror-trailingBreak)::after, .paper .tiptap li > p:has(> br.ProseMirror-trailingBreak)::after { /* The static position would land past the BR, a line below the block; name the last line instead — for an empty block that is also its first. */ @@ -1086,13 +1059,7 @@ content: none; } - .paper.show-formatting-marks .tiptap p::after, - .paper.show-formatting-marks .tiptap h1::after, - .paper.show-formatting-marks .tiptap h2::after, - .paper.show-formatting-marks .tiptap h3::after, - .paper.show-formatting-marks .tiptap h4::after, - .paper.show-formatting-marks .tiptap h5::after, - .paper.show-formatting-marks .tiptap h6::after { + .paper.show-formatting-marks .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)::after { color: transparent; } } diff --git a/tests/unit/deep-headings.test.ts b/tests/unit/deep-headings.test.ts new file mode 100644 index 0000000..dfb2a47 --- /dev/null +++ b/tests/unit/deep-headings.test.ts @@ -0,0 +1,44 @@ +// Heading levels 7–10: both word processors go that far, HTML stops at h6. The level +// must survive both formats — an outline level ODF writes as-is and Word carries in the +// style name — instead of collapsing onto level 6. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt, MAX_HEADING_LEVEL, HEADING_STYLE_OVERRIDES } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { builtinStyleSheet, resolveStyle } from '../../src/lib/styles/styleSheet'; + +type N = any; + +const H = (level: number): N => + ({ type: 'heading', attrs: { level, styleName: `Heading ${level}` }, content: [{ type: 'text', text: `Level ${level}` }] }); + +const doc: N = { type: 'doc', content: [1, 6, 7, 8, 9, 10].map(H) }; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; +const levels = (d: N) => d.content.filter((b: N) => b.type === 'heading').map((b: N) => b.attrs.level); + +describe('heading levels 7–10', () => { + it('offers ten levels, with a built-in style each', () => { + expect(MAX_HEADING_LEVEL).toBe(10); + expect(HEADING_STYLE_OVERRIDES).toHaveLength(10); + const sheet = builtinStyleSheet(); + for (let level = 7; level <= 10; level++) { + expect(resolveStyle(sheet, `Heading ${level}`).text.fontSizePt).toBe(12); + } + }); + + it('round-trips through ODF', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + expect(xml).toContain('text:outline-level="10"'); + expect(levels(importOdt(bytes).content as N)).toEqual([1, 6, 7, 8, 9, 10]); + }); + + it('round-trips through DOCX', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toContain('w:val="Heading10"'); + expect(levels(importDocx(bytes).content as N)).toEqual([1, 6, 7, 8, 9, 10]); + }); +});