feat: headings go to level 10, as both word processors do

HTML stops at h6, so 7-10 render as unknown elements editor.css makes
blocks; their built-in styles continue level 6 at 12pt, since LibreOffice
writes these styles with no properties and resolves them from its own
pool. Both importers read a two-digit level again (the style-name regexes
stopped at one digit). Verified through a LibreOffice re-save of both
formats and in the live editor.
This commit is contained in:
Steffen Becker committed 2026-08-15 01:21:23 +02:00
1 parent 4afd5cd1d5
commit 147034730f
8 files changed
+100 -79

No files matched your search

+1 -2
View File
@@ -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
+5 -1
View File
@@ -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
+6
View File
@@ -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'
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+4 -3
View File
@@ -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.
+36 -69
View File
@@ -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 <ol> from its attr + ancestor chain. The
undecorated <ol> 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;
}
}
+44
View File
@@ -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]);
});
});