smartrte-core 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-beta.5
4
+
5
+ - Add a per-block `lineHeight` attribute (paragraph, heading, blockquote, code block) — a unitless multiplier matching CSS `line-height`'s own unitless mode (`1`, `1.5`, `2`, ...), consistent with how `align`/`indentLevel` are represented. Absence means no override (the browser/font's own natural line-height), not a forced `1`. Full HTML round-trip fidelity via a dedicated `data-smart-line-height` marker — deliberately does **not** fall back to reading a bare CSS `line-height` value on import (unlike `align`/`indentLevel`'s own CSS fallbacks): real captured clipboard fixtures show `line-height` is frequently an ambient, whole-document default baked onto every paragraph, not a deliberate per-paragraph choice, and recognizing it would have silently misattributed that default as an explicit override on every pasted document. Real Word line spacing (`w:spacing`/`w:line`/`w:lineRule="auto"`) on DOCX export; declared `lossy` there (confirmed empirically that DOCX reimport via mammoth drops it) and `unsupported` (content preserved) in Markdown. Inherits `full` PDF fidelity for free, since this package's "Save as PDF" prints the same HTML export.
6
+ - Add a `page_break` atom node — a print/export pagination marker, distinct from the existing `divider` (horizontal rule) node. Exports to a real paginating marker in HTML (and this package's own "Save as PDF", which prints that same HTML) and a real native Word page break in DOCX; declared `unsupported` in Markdown (no pagination concept there), preserved as an inert comment rather than silently dropped. Add an `AtomDeclaration` for `divider` so it can be inserted via the standard atom-insertion command, not just recovered from pasted `<hr>`. Fix `divider` silently exporting as an empty paragraph in DOCX (no visible line at all).
7
+ - Fix a real HTML round-trip bug found while building the above: a div-tagged atom's own round-trip marker (`block_formula`, and the new `page_break`) was silently deleted on reimport — treated as a meaningless transparent wrapper div and recursed into (destroying it) before ever reaching the atom-parsing logic. See `docs/bugs/html-import-div-atom-markers-treated-as-transparent-wrapper.md`.
8
+ - Fix formulas rendering as invisible empty placeholders in "Save as PDF" output — the print document never baked real KaTeX HTML (the same gap already fixed once for a different static-HTML consumer) and never loaded KaTeX's own CSS at all (a brand-new, isolated print window shares none of the host page's stylesheets). See `docs/bugs/pdf-export-formula-invisible-and-misleading-label.md`.
9
+
10
+ ## 1.0.0-beta.4
11
+
12
+ - Fix HTML import (paste and loading a document's initial value) rendering real content as unreadable `[Unsupported: ...]` placeholders when a bare `<span>`/`<b>`/other inline-formatting element sits directly at the document root, or is interleaved between real blocks (a `<blockquote>`, `<div>`-wrapped lines, images) with no wrapping `<p>` — a common shape from legacy, pre-migration editor exports. Also fixes the same gap in `<blockquote>`/table cells/list items for content interleaved between blocks (previously only content entirely before any block content was recovered correctly).
13
+
3
14
  ## 1.0.0-beta.3
4
15
 
5
16
  - Add `href`/`target` (a clickable link), `borderRadius` (corner rounding), and license metadata fields (`licenseDescription`, `licenseSourceUrl`, `licenseType`, `licenseVersion`, `licenseAttribution`) to the image atom schema. `href` is validated the same way as the existing `link` mark; exported HTML wraps a linked image in a real `<a>` for portability outside the editor. Fix a pre-existing renderer gap where clearing an image's `align` back to unset never removed the CSS float/display/margin it had previously applied.
@@ -14,4 +14,8 @@ export const atomDeclarations = [
14
14
  { type: "block_formula", kind: "formula", group: "block", validate: formulaValid },
15
15
  { type: "video", kind: "video", group: "block", validate: mediaValid("video") },
16
16
  { type: "audio", kind: "audio", group: "block", validate: mediaValid("audio") },
17
+ // No attrs to validate - a divider/page break carries none (see
18
+ // atom/schema.ts's own comments on both node types).
19
+ { type: "divider", kind: "divider", group: "block", validate: () => true },
20
+ { type: "page_break", kind: "pageBreak", group: "block", validate: () => true },
17
21
  ];
@@ -4,9 +4,20 @@ export declare const atomToHtml: (node: SmartElementNode, options?: {
4
4
  renderFormulaHtml?: boolean;
5
5
  }) => string;
6
6
  export declare const atomFromHtmlElement: (rawElement: Element) => SmartElementNode | null;
7
+ /**
8
+ * page_break: Markdown has no pagination concept at all (declared
9
+ * `unsupported` in formats/fidelity.ts) - emitting nothing would silently
10
+ * delete the marker with no trace, the same class of bug this project has
11
+ * hit before for images/formulas (see formats/fidelity.ts's own
12
+ * images-media/formulas notes on that history). An HTML comment is inert
13
+ * in every real Markdown renderer (so it never appears as visible garbage
14
+ * text) but keeps the marker's *position* recorded in the exported file -
15
+ * genuinely honest `unsupported`, not a round-trippable format: nothing
16
+ * parses this comment back into a page_break node on import.
17
+ */
7
18
  export declare const atomToMarkdown: (node: SmartElementNode) => string;
8
19
  export interface AtomDocxRun {
9
- readonly kind: "image" | "text";
20
+ readonly kind: "image" | "text" | "pageBreak";
10
21
  readonly source: string;
11
22
  readonly alt?: string;
12
23
  }
@@ -16,9 +27,29 @@ export interface AtomDocxRun {
16
27
  * <m:oMath> zone, not translated to real OMML and not rendered as an
17
28
  * image. Word will show the raw LaTeX string, not typeset math - this is
18
29
  * `kind: "text"`, not `"image"`, to describe that honestly.
30
+ *
31
+ * page_break returns `kind: "pageBreak"` (`source` unused, kept empty) -
32
+ * the real docx/export.ts blockXml dispatcher turns this into a genuine
33
+ * `<w:br w:type="page"/>` run, matching what Word's own Ctrl+Enter inserts,
34
+ * not a visual-only text marker.
19
35
  */
20
36
  export declare const atomToDocx: (node: SmartElementNode) => AtomDocxRun;
37
+ /**
38
+ * IMPORTANT: this function is NOT what actually produces this package's
39
+ * real "Save as PDF" output today. That feature (react/src/adapters/
40
+ * pdfPrint.ts's printSmartDocumentAsPdf) opens the browser's native print
41
+ * dialog against `buildPdfPrintDocument` (formats/pdf/format.ts), which
42
+ * calls `serializeCanonicalListHtml` - i.e. `atomToHtml` above, not this
43
+ * function. This function only backs the per-node PDF `FeatureFormatCodec`
44
+ * fidelity-declaration cell (featureCodecs.ts) and its own unit tests - a
45
+ * real pagination pipeline built around per-node PDF primitives (a PDF
46
+ * library call, not a browser print) would consume this `"pageBreak"` kind
47
+ * to start a new page, but no such pipeline exists in this codebase today.
48
+ * Real page-break pagination in the actual "Save as PDF" feature comes
49
+ * entirely from atomToHtml's `break-before: page` marker, verified via
50
+ * e2e against the real print document, not via this function.
51
+ */
21
52
  export declare const atomToPdf: (node: SmartElementNode) => {
22
- kind: "image" | "text";
53
+ kind: "image" | "text" | "pageBreak";
23
54
  value: string;
24
55
  };
@@ -63,6 +63,16 @@ export const atomToHtml = (node, options) => {
63
63
  }
64
64
  if (node.type === "divider")
65
65
  return `<hr data-smart-id="${escape(node.id)}" data-smart-type="divider">`;
66
+ // Both `break-before: page` (the current CSS Fragmentation property) and
67
+ // `page-break-before: always` (its older, still widely-honored alias) are
68
+ // set directly on the marker so it actually paginates wherever this HTML
69
+ // is printed - including this package's own "Save as PDF" (a real browser
70
+ // print of this exact HTML; see react/src/adapters/pdfPrint.ts's own doc
71
+ // comment). A screen-media renderer (the live editor) ignores both
72
+ // properties entirely, so this is safe to always emit rather than gating
73
+ // behind @media print.
74
+ if (node.type === "page_break")
75
+ return `<div data-smart-id="${escape(node.id)}" data-smart-type="page_break" style="break-before: page; page-break-before: always;" role="separator" aria-label="Page break"></div>`;
66
76
  throw new Error(`Unsupported atom type "${node.type}".`);
67
77
  };
68
78
  export const atomFromHtmlElement = (rawElement) => {
@@ -108,8 +118,21 @@ export const atomFromHtmlElement = (rawElement) => {
108
118
  }
109
119
  if (type === "divider" || element.tagName === "HR")
110
120
  return { type: "divider", id };
121
+ if (type === "page_break")
122
+ return { type: "page_break", id };
111
123
  return null;
112
124
  };
125
+ /**
126
+ * page_break: Markdown has no pagination concept at all (declared
127
+ * `unsupported` in formats/fidelity.ts) - emitting nothing would silently
128
+ * delete the marker with no trace, the same class of bug this project has
129
+ * hit before for images/formulas (see formats/fidelity.ts's own
130
+ * images-media/formulas notes on that history). An HTML comment is inert
131
+ * in every real Markdown renderer (so it never appears as visible garbage
132
+ * text) but keeps the marker's *position* recorded in the exported file -
133
+ * genuinely honest `unsupported`, not a round-trippable format: nothing
134
+ * parses this comment back into a page_break node on import.
135
+ */
113
136
  export const atomToMarkdown = (node) => {
114
137
  if (node.type === "image" || node.type === "block_image")
115
138
  return `![${String(node.attrs?.alt || "")}](${String(node.attrs?.src || "")})`;
@@ -120,6 +143,8 @@ export const atomToMarkdown = (node) => {
120
143
  return `[${node.type}: ${String(node.attrs?.src || "")}](${String(node.attrs?.src || "")})`;
121
144
  if (node.type === "divider")
122
145
  return "---";
146
+ if (node.type === "page_break")
147
+ return "<!-- page break -->";
123
148
  return "";
124
149
  };
125
150
  /**
@@ -128,6 +153,11 @@ export const atomToMarkdown = (node) => {
128
153
  * <m:oMath> zone, not translated to real OMML and not rendered as an
129
154
  * image. Word will show the raw LaTeX string, not typeset math - this is
130
155
  * `kind: "text"`, not `"image"`, to describe that honestly.
156
+ *
157
+ * page_break returns `kind: "pageBreak"` (`source` unused, kept empty) -
158
+ * the real docx/export.ts blockXml dispatcher turns this into a genuine
159
+ * `<w:br w:type="page"/>` run, matching what Word's own Ctrl+Enter inserts,
160
+ * not a visual-only text marker.
131
161
  */
132
162
  export const atomToDocx = (node) => node.type === "formula" || node.type === "block_formula"
133
163
  ? { kind: "text", source: String(node.attrs?.source || "") }
@@ -135,9 +165,27 @@ export const atomToDocx = (node) => node.type === "formula" || node.type === "bl
135
165
  ? { kind: "image", source: String(node.attrs?.src || ""), alt: String(node.attrs?.alt || "") }
136
166
  : node.type === "divider"
137
167
  ? { kind: "text", source: "---" }
138
- : { kind: "text", source: `[${node.type}: ${String(node.attrs?.src || "")}]` };
168
+ : node.type === "page_break"
169
+ ? { kind: "pageBreak", source: "" }
170
+ : { kind: "text", source: `[${node.type}: ${String(node.attrs?.src || "")}]` };
171
+ /**
172
+ * IMPORTANT: this function is NOT what actually produces this package's
173
+ * real "Save as PDF" output today. That feature (react/src/adapters/
174
+ * pdfPrint.ts's printSmartDocumentAsPdf) opens the browser's native print
175
+ * dialog against `buildPdfPrintDocument` (formats/pdf/format.ts), which
176
+ * calls `serializeCanonicalListHtml` - i.e. `atomToHtml` above, not this
177
+ * function. This function only backs the per-node PDF `FeatureFormatCodec`
178
+ * fidelity-declaration cell (featureCodecs.ts) and its own unit tests - a
179
+ * real pagination pipeline built around per-node PDF primitives (a PDF
180
+ * library call, not a browser print) would consume this `"pageBreak"` kind
181
+ * to start a new page, but no such pipeline exists in this codebase today.
182
+ * Real page-break pagination in the actual "Save as PDF" feature comes
183
+ * entirely from atomToHtml's `break-before: page` marker, verified via
184
+ * e2e against the real print document, not via this function.
185
+ */
139
186
  export const atomToPdf = (node) => node.type === "formula" || node.type === "block_formula" ? { kind: "text", value: String(node.attrs?.source || "") }
140
187
  : node.type === "video" ? { kind: "image", value: String(node.attrs?.poster || node.attrs?.src || "") }
141
188
  : node.type === "image" || node.type === "block_image" ? { kind: "image", value: String(node.attrs?.src || "") }
142
189
  : node.type === "divider" ? { kind: "text", value: "---" }
143
- : { kind: "text", value: String(node.attrs?.src || "") };
190
+ : node.type === "page_break" ? { kind: "pageBreak", value: "" }
191
+ : { kind: "text", value: String(node.attrs?.src || "") };
@@ -55,4 +55,13 @@ export const atomNodeSpecs = [
55
55
  // case for "hr"), so every pasted <hr> fell to the generic
56
56
  // "unrecognized tag" fallback and rendered as "[Unsupported: hr]".
57
57
  { type: "divider", group: "block", atomic: true, selectable: true, marks: "" },
58
+ // A print/export pagination marker - deliberately its own node type, not
59
+ // a `divider` variant: a horizontal line is decorative content with no
60
+ // export-time meaning beyond "draw a line," while a page break's entire
61
+ // purpose is what happens at export/print time (real PDF pagination, a
62
+ // DOCX page-break run, `break-before: page` in printed HTML) - see
63
+ // atom/formats.ts's atomToPdf/atomToDocx and formats/docx/export.ts for
64
+ // where that distinction actually matters. No content, no attrs, same as
65
+ // divider.
66
+ { type: "page_break", group: "block", atomic: true, selectable: true, marks: "" },
58
67
  ];
@@ -1,6 +1,6 @@
1
1
  import type { Attrs, SmartDocument, SmartOperation, SmartSchema } from "../types.js";
2
2
  import type { PositionLookup, ResolvedScope } from "../scope/types.js";
3
- export type AtomKind = "image" | "formula" | "video" | "audio";
3
+ export type AtomKind = "image" | "formula" | "video" | "audio" | "divider" | "pageBreak";
4
4
  export type AtomStatus = "pending" | "ready" | "error";
5
5
  export interface AtomDeclaration {
6
6
  readonly type: string;
@@ -10,6 +10,8 @@ export interface CanonicalDocxBlock {
10
10
  readonly alignment?: "left" | "center" | "right" | "justify";
11
11
  /** DOCX left indentation in twips (720 twips per canonical indent level). */
12
12
  readonly indentTwips?: number;
13
+ /** Word's native "multiple" line spacing unit: 240ths of a single line (240 = 1.0, 360 = 1.5). See formats/docx/export.ts's lineHeightOf for the same convention in the real exporter. */
14
+ readonly lineSpacing240ths?: number;
13
15
  readonly language?: string;
14
16
  readonly quoteDepth?: number;
15
17
  }
@@ -20,6 +20,7 @@ export const blockToDocxEntry = (node, quoteDepth = 0) => {
20
20
  const alignment = typeof node.attrs?.align === "string" && ["left", "center", "right", "justify"].includes(node.attrs.align)
21
21
  ? node.attrs.align : undefined;
22
22
  const indentLevel = Math.max(0, Number(node.attrs?.indentLevel) || 0);
23
+ const lineHeight = Number(node.attrs?.lineHeight) || 0;
23
24
  return {
24
25
  nodeId: node.id,
25
26
  kind: node.type === "code_block" ? "code" : node.type === "heading" ? "heading" : "paragraph",
@@ -28,6 +29,7 @@ export const blockToDocxEntry = (node, quoteDepth = 0) => {
28
29
  ...(level ? { outlineLevel: level - 1 } : {}),
29
30
  ...(alignment ? { alignment } : {}),
30
31
  ...(indentLevel ? { indentTwips: indentLevel * 720 } : {}),
32
+ ...(lineHeight ? { lineSpacing240ths: Math.round(lineHeight * 240) } : {}),
31
33
  ...(node.type === "code_block" && typeof node.attrs?.language === "string" ? { language: node.attrs.language } : {}),
32
34
  ...(quoteDepth ? { quoteDepth } : {}),
33
35
  };
@@ -1,7 +1,19 @@
1
1
  const stringAttr = { validate: (value) => typeof value === "string" };
2
2
  const alignmentAttr = { validate: (value) => ["left", "center", "right", "justify"].includes(String(value)) };
3
3
  const indentLevelAttr = { validate: (value) => Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10 };
4
- const blockAttrs = { align: alignmentAttr, indentLevel: indentLevelAttr };
4
+ /**
5
+ * A unitless multiplier, matching CSS `line-height`'s own unitless mode
6
+ * (e.g. `1.5` means "1.5x the element's own font size") - deliberately not
7
+ * a fixed px/pt value, which would stop scaling correctly the moment
8
+ * `fontSize` changes on the same block. Range is generous but bounded
9
+ * (Google Docs' own preset list tops out at 2.5; 0.1-10 comfortably covers
10
+ * every preset plus realistic custom values without accepting nonsense
11
+ * like a negative multiplier). Absence of this attribute (not "1") means
12
+ * "no override" - the block renders at the browser/font's own natural
13
+ * line-height, exactly like `align`/`indentLevel` already work.
14
+ */
15
+ const lineHeightAttr = { validate: (value) => typeof value === "number" && Number.isFinite(value) && value >= 0.1 && value <= 10 };
16
+ const blockAttrs = { align: alignmentAttr, indentLevel: indentLevelAttr, lineHeight: lineHeightAttr };
5
17
  /** Node specs owned by the block family - extracted out of foundation/schema.ts's previously hardcoded foundationSchema literal (Phase 10). */
6
18
  export const blockNodeSpecs = [
7
19
  { type: "paragraph", group: "block", content: "inline*", attributes: blockAttrs },
@@ -145,9 +145,20 @@ const inlineRun = (node, context) => {
145
145
  };
146
146
  const alignmentOf = (node) => typeof node.attrs?.align === "string" ? node.attrs.align : undefined;
147
147
  const indentLevelOf = (node) => Number(node.attrs?.indentLevel) || 0;
148
+ /**
149
+ * Word's native "multiple" line spacing: `w:line` is in 240ths of a single
150
+ * line (240 = single/1.0, 360 = 1.5, 480 = double/2.0 - the same
151
+ * convention Word's own UI uses), `w:lineRule="auto"` is what makes it a
152
+ * multiplier rather than an exact fixed height (`"exact"`/`"atLeast"`
153
+ * would instead be a fixed point size, which is deliberately not what
154
+ * this feature stores - see schema.ts's lineHeightAttr doc comment on why
155
+ * a unitless multiplier was chosen over a fixed value).
156
+ */
157
+ const lineHeightOf = (node) => Number(node.attrs?.lineHeight) || 0;
148
158
  const paragraphProperties = (block, extra = "") => [
149
159
  alignmentOf(block) ? `<w:jc w:val="${xmlEscape(alignmentOf(block))}"/>` : "",
150
160
  indentLevelOf(block) ? `<w:ind w:left="${indentLevelOf(block) * 480}"/>` : "",
161
+ lineHeightOf(block) ? `<w:spacing w:line="${Math.round(lineHeightOf(block) * 240)}" w:lineRule="auto"/>` : "",
151
162
  extra,
152
163
  ].join("");
153
164
  const paragraphXml = (children, block, context, extraProperties = "") => {
@@ -236,6 +247,20 @@ const blockXml = (block, context, listLevel = 0) => {
236
247
  return paragraphXml([{ ...block, type: "image" }], block, context);
237
248
  if (block.type === "block_formula")
238
249
  return paragraphXml([{ ...block, type: "formula" }], block, context);
250
+ // Previously fell through to the generic fallback below (an empty
251
+ // paragraph, since a divider has neither title nor src) - a horizontal
252
+ // line silently vanished on DOCX export with no visible trace at all.
253
+ // A bottom-bordered empty paragraph is exactly what Word's own
254
+ // Insert > Horizontal Line command produces.
255
+ if (block.type === "divider")
256
+ return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>';
257
+ // A real Word page break (matches Ctrl+Enter) - not a visual-only text
258
+ // marker. See atom/formats.ts's atomToDocx doc comment: mammoth's HTML
259
+ // conversion (this package's own DOCX import path) does not preserve
260
+ // this on re-import, confirmed via a real export/import round-trip test
261
+ // (format.test.ts) - export fidelity is real, import fidelity is not.
262
+ if (block.type === "page_break")
263
+ return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>';
239
264
  return paragraphXml([{ type: "text", text: String(block.attrs?.title || block.attrs?.src || "") }], block, context);
240
265
  };
241
266
  const numberingLevelXml = (level, format, text, font) => `<w:lvl w:ilvl="${level}"><w:start w:val="1"/><w:numFmt w:val="${format}"/><w:lvlText w:val="${text}"/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="${720 + level * 360}"/></w:tabs><w:ind w:left="${720 + level * 360}" w:hanging="360"/></w:pPr>${font ? `<w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}"/></w:rPr>` : ""}</w:lvl>`;
@@ -1,18 +1,19 @@
1
1
  import type { FeatureFormatCodec } from "./codec.js";
2
2
  import { type FidelityFeature } from "./fidelity.js";
3
3
  /**
4
- * The 44 (feature x format) FeatureFormatCodec declarations Phase 9 SS3
5
- * gate 3 requires. feature/format/fidelity/note are mechanically derived
6
- * from builtInFormatFidelity (fidelity.ts) - that table is the single
7
- * source of truth for fidelity claims; this file must never restate a
8
- * level or note independently of it.
4
+ * The (feature x format) FeatureFormatCodec declarations Phase 9 SS3 gate 3
5
+ * requires (44 at that gate's close; "page-break" added later brings the
6
+ * total to 48). feature/format/fidelity/note are mechanically derived from
7
+ * builtInFormatFidelity (fidelity.ts) - that table is the single source of
8
+ * truth for fidelity claims; this file must never restate a level or note
9
+ * independently of it.
9
10
  *
10
11
  * parse/serialize are only attached where a genuine single-node function
11
12
  * already exists, never a fabricated per-node wrapper around whole-document
12
13
  * logic:
13
14
  * - atom/formats.ts's atomTo{Html,Markdown,Docx,Pdf} and atomFromHtmlElement
14
- * operate on one SmartElementNode at a time, so images-media and formulas
15
- * get real wiring for all four formats (Phase 9).
15
+ * operate on one SmartElementNode at a time, so images-media, formulas,
16
+ * and page-break get real wiring for all four formats.
16
17
  * - marks/formats.ts's docxProperties and block/formats.ts's blockToDocxEntry
17
18
  * (Phase 11 Tier 2, per docs/PHASE_9_CODEC_REFACTOR_SCOPE.md's recommended
18
19
  * marks-then-blocks order) were already genuinely single-node - or, for
@@ -4,18 +4,19 @@ import { blockToDocxEntry } from "../block/formats.js";
4
4
  import { builtInFormatFidelity } from "./fidelity.js";
5
5
  const asAtom = (node) => node;
6
6
  /**
7
- * The 44 (feature x format) FeatureFormatCodec declarations Phase 9 SS3
8
- * gate 3 requires. feature/format/fidelity/note are mechanically derived
9
- * from builtInFormatFidelity (fidelity.ts) - that table is the single
10
- * source of truth for fidelity claims; this file must never restate a
11
- * level or note independently of it.
7
+ * The (feature x format) FeatureFormatCodec declarations Phase 9 SS3 gate 3
8
+ * requires (44 at that gate's close; "page-break" added later brings the
9
+ * total to 48). feature/format/fidelity/note are mechanically derived from
10
+ * builtInFormatFidelity (fidelity.ts) - that table is the single source of
11
+ * truth for fidelity claims; this file must never restate a level or note
12
+ * independently of it.
12
13
  *
13
14
  * parse/serialize are only attached where a genuine single-node function
14
15
  * already exists, never a fabricated per-node wrapper around whole-document
15
16
  * logic:
16
17
  * - atom/formats.ts's atomTo{Html,Markdown,Docx,Pdf} and atomFromHtmlElement
17
- * operate on one SmartElementNode at a time, so images-media and formulas
18
- * get real wiring for all four formats (Phase 9).
18
+ * operate on one SmartElementNode at a time, so images-media, formulas,
19
+ * and page-break get real wiring for all four formats.
19
20
  * - marks/formats.ts's docxProperties and block/formats.ts's blockToDocxEntry
20
21
  * (Phase 11 Tier 2, per docs/PHASE_9_CODEC_REFACTOR_SCOPE.md's recommended
21
22
  * marks-then-blocks order) were already genuinely single-node - or, for
@@ -45,7 +46,7 @@ export const builtInFeatureFormatCodecs = builtInFormatFidelity.flatMap((contrac
45
46
  fidelity: capability.level,
46
47
  note: capability.note,
47
48
  };
48
- if (contract.feature === "images-media" || contract.feature === "formulas") {
49
+ if (contract.feature === "images-media" || contract.feature === "formulas" || contract.feature === "page-break") {
49
50
  if (format === "html") {
50
51
  return {
51
52
  ...base,
@@ -62,7 +63,7 @@ export const builtInFeatureFormatCodecs = builtInFormatFidelity.flatMap((contrac
62
63
  if (format === "docx" && (contract.feature === "inline-marks" || contract.feature === "colors-fonts-sizes")) {
63
64
  return { ...base, serialize: (node) => markRunDocxProperties(node) };
64
65
  }
65
- if (format === "docx" && (contract.feature === "headings-alignment" || contract.feature === "blockquote-code")) {
66
+ if (format === "docx" && (contract.feature === "headings-alignment" || contract.feature === "blockquote-code" || contract.feature === "line-height")) {
66
67
  return { ...base, serialize: (node) => blockToDocxEntry(asAtom(node)) };
67
68
  }
68
69
  return base;
@@ -1,7 +1,7 @@
1
1
  import type { FormatFidelityLevel, FormatId } from "./codec.js";
2
2
  export type FidelityFormat = FormatId;
3
3
  export type FidelityLevel = FormatFidelityLevel;
4
- export type FidelityFeature = "inline-marks" | "colors-fonts-sizes" | "headings-alignment" | "blockquote-code" | "lists" | "checklists" | "tables" | "links" | "images-media" | "formulas" | "special-characters";
4
+ export type FidelityFeature = "inline-marks" | "colors-fonts-sizes" | "headings-alignment" | "blockquote-code" | "lists" | "checklists" | "tables" | "links" | "images-media" | "formulas" | "special-characters" | "page-break" | "line-height";
5
5
  export interface FormatFidelityCapability {
6
6
  level: FidelityLevel;
7
7
  note: string;
@@ -119,5 +119,23 @@ export const builtInFormatFidelity = [
119
119
  pdf: capability("semantic", "Depends on font embedding and extractor Unicode maps."),
120
120
  },
121
121
  },
122
+ {
123
+ feature: "page-break",
124
+ formats: {
125
+ html: capability("full", "A marker element with `break-before: page`/`page-break-before: always` round-trips exactly, and genuinely paginates wherever the exported HTML is printed - including this package's own 'Save as PDF' (a real browser print of this HTML, see atom/formats.ts's atomToHtml doc comment)."),
126
+ markdown: capability("unsupported", "Markdown has no pagination concept. Exported as an inert `<!-- page break -->` HTML comment so the position is not silently dropped (matching this project's own history of atoms disappearing with no trace on markdown export); nothing parses this comment back into a page break on import, so this is genuinely `unsupported`, not merely undeclared."),
127
+ docx: capability("lossy", "A real native Word page break (`<w:br w:type=\"page\"/>`) is emitted on export - Word paginates correctly when the file is opened. Confirmed empirically (format.test.ts) that mammoth's HTML conversion (this package's own DOCX import path) drops the page break entirely on re-import, with no trace - export fidelity is real, import fidelity is not."),
128
+ pdf: capability("full", "This package's actual 'Save as PDF' is a real browser print of the same HTML export (react/src/adapters/pdfPrint.ts), so it inherits the html row's real pagination exactly - verified end to end via Playwright's own page.pdf() against the real print document, asserting a real page count increase. The separate atomToPdf per-node function (used only by this fidelity table's own test suite, not the real feature) is not the mechanism that produces this."),
129
+ },
130
+ },
131
+ {
132
+ feature: "line-height",
133
+ formats: {
134
+ html: capability("full", "A unitless multiplier (matching CSS line-height's own unitless mode) round-trips exactly via a data-smart-line-height attribute plus an inline `line-height:` style declaration."),
135
+ markdown: capability("unsupported", "Markdown has no line-spacing concept at all. The paragraph's own text is preserved in full (line-height is metadata about how it's displayed, not content) - only the spacing value itself is dropped, matching this project's non-destructive-fallback discipline (nothing else about the paragraph is lost)."),
136
+ docx: capability("lossy", "Word's native \"multiple\" line spacing (`w:spacing w:line=\"...\" w:lineRule=\"auto\"`) is emitted on export - Word itself displays the correct spacing when the file is opened. Confirmed empirically (format.test.ts) that mammoth's HTML conversion (this package's own DOCX import path) drops the spacing value entirely on re-import - export fidelity is real, import fidelity is not, the same asymmetry already documented for page-break."),
137
+ pdf: capability("full", "This package's actual 'Save as PDF' is a real browser print of the same HTML export, so it inherits the html row's real, correctly-spaced layout exactly - verified against the real print document's computed styles, not assumed."),
138
+ },
139
+ },
122
140
  ];
123
141
  export const getFormatFidelity = (feature, format) => builtInFormatFidelity.find((entry) => entry.feature === feature).formats[format];
@@ -160,12 +160,35 @@ export const importPdfDocument = async (arrayBuffer) => {
160
160
  return reconstructPdfPages(pages);
161
161
  };
162
162
  export const buildPdfPrintDocument = (document) => {
163
- const html = serializeCanonicalListHtml(document, { clean: true });
163
+ // Same static-HTML-consumer gap as Sootr's Web Preview (see docs/bugs/
164
+ // formula-not-rendered-in-static-html-consumers.md): this print window
165
+ // never runs the live editor's own KaTeX rendering against its DOM, so
166
+ // without renderFormulaHtml every formula/block_formula serializes as an
167
+ // empty <span>/<div> - present in the markup, invisible on the printed
168
+ // page and in the resulting PDF. Bakes real KaTeX HTML+MathML in instead,
169
+ // exactly like the Web Preview fix.
170
+ const html = serializeCanonicalListHtml(document, { clean: true, renderFormulaHtml: true });
164
171
  return `<!doctype html>
165
172
  <html lang="en">
166
173
  <head>
167
174
  <meta charset="utf-8">
168
175
  <title>Smart RTE PDF Export</title>
176
+ <!--
177
+ KaTeX's rendered HTML (baked in above) is a set of unstyled <span>s
178
+ without this - it is a CSS framework, not self-describing markup. This
179
+ print window is a brand-new, isolated document (opened via
180
+ window.open + document.write - see react/src/adapters/pdfPrint.ts) that
181
+ shares none of the host page's own stylesheets, so it needs its own
182
+ copy regardless of whether the host has already loaded KaTeX's CSS for
183
+ the live editor. Matches this package's own documented setup
184
+ convention (README.md's KaTeX CDN <link>) rather than inventing a
185
+ second one - pinned to the exact version this package bundles
186
+ (package.json's "katex" dependency) so the JS that generated the
187
+ markup and the CSS that styles it never drift apart. Requires network
188
+ access at export time, the same as the live editor already requires
189
+ for its own KaTeX CSS.
190
+ -->
191
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/katex.min.css">
169
192
  <style>
170
193
  @page { margin: 18mm; }
171
194
  html, body { background: #fff; }
@@ -17,7 +17,7 @@ const isEditorUiNode = (node) => attr(node, "data-smart-ui") !== undefined || at
17
17
  * meaningful block content - most often a <table> - in one or more <div>s
18
18
  * (table-wrapper divs, layout divs) that carry no semantic meaning of
19
19
  * their own. parseBlock has no case for any of these tags; see
20
- * parseBlockList below for how they're unwrapped instead of swallowed.
20
+ * parseMixedBlockContent below for how they're unwrapped instead of swallowed.
21
21
  */
22
22
  const TRANSPARENT_CONTAINER_TAGS = ["div", "section", "article", "figure"];
23
23
  /** Shared by both the inline `image` and block `block_image` parsing branches below - radius/license round-trip identically for both, only href/target's link-mark-fallback behavior deliberately differs between them (see each call site's own comment). */
@@ -91,14 +91,18 @@ const effectiveStyle = (node, depth) => {
91
91
  : undefined;
92
92
  };
93
93
  const blockAttributes = (node) => {
94
+ const lineHeight = Number(node.attrs?.lineHeight);
95
+ const hasLineHeight = Number.isFinite(lineHeight) && lineHeight > 0;
94
96
  const declarations = [
95
97
  typeof node.attrs?.htmlStyle === "string" ? node.attrs.htmlStyle.replace(/;\s*$/, "") : "",
96
98
  typeof node.attrs?.align === "string" ? `text-align:${node.attrs.align}` : "",
97
99
  Number(node.attrs?.indentLevel) > 0 ? `margin-inline-start:${Number(node.attrs?.indentLevel) * 2}em` : "",
100
+ hasLineHeight ? `line-height:${lineHeight}` : "",
98
101
  ].filter(Boolean);
99
102
  return [
100
103
  node.attrs?.align ? ` data-smart-align="${escapeHtml(node.attrs.align)}"` : "",
101
104
  Number(node.attrs?.indentLevel) > 0 ? ` data-smart-indent="${escapeHtml(node.attrs?.indentLevel)}"` : "",
105
+ hasLineHeight ? ` data-smart-line-height="${escapeHtml(lineHeight)}"` : "",
102
106
  declarations.length ? ` style="${escapeHtml(declarations.join(";"))}"` : "",
103
107
  ].join("");
104
108
  };
@@ -109,7 +113,7 @@ const serializeBlock = (node, includeIds, listDepth = 0, tableCellA11y) => {
109
113
  return raw.html;
110
114
  }
111
115
  const id = includeIds ? ` data-smart-id="${escapeHtml(node.id)}"` : "";
112
- if (["block_image", "block_formula", "video", "audio", "divider"].includes(node.type))
116
+ if (["block_image", "block_formula", "video", "audio", "divider", "page_break"].includes(node.type))
113
117
  return atomToHtml(node, { renderFormulaHtml: renderFormulaHtmlMode });
114
118
  if (node.type === "paragraph" || node.type === "heading") {
115
119
  const tag = node.type === "heading" ? `h${String(node.attrs?.level || 1)}` : "p";
@@ -385,6 +389,26 @@ const parsedBlockAttrs = (node) => {
385
389
  attrs.align = align;
386
390
  if (Number.isInteger(indent) && indent > 0)
387
391
  attrs.indentLevel = indent;
392
+ // Deliberately NOT falling back to a bare CSS `line-height` value the way
393
+ // align/indent fall back to their own CSS equivalents above - confirmed
394
+ // directly against two real captured clipboard fixtures
395
+ // (google-docs-clipboard.clipboard.json, native-smart-rte-clipboard.
396
+ // clipboard.json) that a real-world `line-height` is very often an
397
+ // AMBIENT/inherited value (a whole document's own default line spacing,
398
+ // or - for the native fixture - literally this editor's own global
399
+ // `line-height: 1.6` from theme.ts, baked onto every element by the
400
+ // browser's clipboard serialization, not a value the user ever
401
+ // deliberately set on that one paragraph) rather than a genuine
402
+ // per-paragraph override. `text-align`/`margin-inline-start` do not have
403
+ // this problem in practice - browsers do not routinely bake an ambient
404
+ // default onto every paragraph's inline style the way they do for
405
+ // line-height. Recognizing the bare CSS value would silently attach a
406
+ // meaningless "explicit" lineHeight to every pasted paragraph. Only this
407
+ // app's own explicit `data-smart-line-height` marker (written exclusively
408
+ // when the user deliberately applied it via the toolbar) is trusted.
409
+ const lineHeight = Number(attr(node, "data-smart-line-height"));
410
+ if (Number.isFinite(lineHeight) && lineHeight >= 0.1 && lineHeight <= 10)
411
+ attrs.lineHeight = lineHeight;
388
412
  return attrs;
389
413
  };
390
414
  const rawText = (node) => node.nodeName === "#text" ? node.value || ""
@@ -426,6 +450,12 @@ const parseBlock = (node) => {
426
450
  return { type: "block_formula", id: generatedId(node, "formula"), attrs: { source: attr(node, "data-smart-formula") || rawText(node), notation: attr(node, "data-smart-notation") === "mathml" ? "mathml" : "latex" } };
427
451
  if (tag === "hr")
428
452
  return { type: "divider", id: generatedId(node, "divider") };
453
+ // No third-party HTML tag means "page break" the way <hr> means
454
+ // "horizontal rule" - only this app's own round-tripped export (the
455
+ // data-smart-type marker) is recognized, same as block_image/
456
+ // block_formula above.
457
+ if (declaredAtom === "page_break")
458
+ return { type: "page_break", id: generatedId(node, "pagebreak") };
429
459
  // A third-party <img> sitting directly at block level (not wrapped in a
430
460
  // <p>) - the ordinary shape for a standalone content photo on most real
431
461
  // websites, e.g. a bare <img> between paragraphs or inside a <figure>
@@ -485,17 +515,10 @@ const parseBlock = (node) => {
485
515
  if (tag === "blockquote") {
486
516
  // Real-world exports (Sootr among them) put inline content - <span>
487
517
  // wrapper runs, bare text, <br> - directly inside <blockquote> with no
488
- // wrapping <p>. Without this split, elementChildren+parseBlock alone
489
- // sent every such child through the generic "unrecognized tag" fallback
490
- // at the bottom of this function, producing an unknown block node per
491
- // span (rendered as "[Unsupported: span]") instead of parsed text/marks
492
- // - the same directInline pattern td/li already use below.
518
+ // wrapping <p>. See parseMixedBlockContent's own comment for why this
519
+ // must be grouped run-by-run rather than collected into one paragraph.
493
520
  const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
494
- const isBlockLike = (child) => blockTags.includes(child.tagName || "") || TRANSPARENT_CONTAINER_TAGS.includes(child.tagName || "");
495
- const children = parseBlockList(elementChildren(node).filter(isBlockLike));
496
- const directInline = (node.childNodes || []).filter((child) => !child.tagName || !isBlockLike(child)).flatMap((child) => textWithMarks(child));
497
- if (directInline.length)
498
- children.unshift({ type: "paragraph", id: createNodeId(), children: directInline });
521
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
499
522
  return {
500
523
  type: "blockquote", id: generatedId(node, "quote"),
501
524
  ...(Object.keys(parsedBlockAttrs(node)).length ? { attrs: parsedBlockAttrs(node) } : {}),
@@ -584,11 +607,7 @@ const parseBlock = (node) => {
584
607
  if (verticalAlign)
585
608
  cellAttrs.verticalAlign = verticalAlign;
586
609
  const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
587
- const isBlockLike = (child) => blockTags.includes(child.tagName || "") || TRANSPARENT_CONTAINER_TAGS.includes(child.tagName || "");
588
- const children = parseBlockList(elementChildren(node).filter(isBlockLike));
589
- const directInline = (node.childNodes || []).filter((child) => !child.tagName || !isBlockLike(child)).flatMap((child) => textWithMarks(child));
590
- if (directInline.length)
591
- children.unshift({ type: "paragraph", id: createNodeId(), children: directInline });
610
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
592
611
  if (!children.length)
593
612
  children.push({ type: "paragraph", id: createNodeId(), children: [] });
594
613
  return { type: "table_cell", id: generatedId(node, "cell"), attrs: cellAttrs, children };
@@ -636,17 +655,8 @@ const parseBlock = (node) => {
636
655
  attrs.htmlStyle = htmlStyle;
637
656
  if (Number.isInteger(value) && value >= 1)
638
657
  attrs.numberOverride = value;
639
- const children = [];
640
- const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "div", "table", "figure"];
641
- const inlineNodes = (node.childNodes || []).filter((child) => !child.tagName || !blockTags.includes(child.tagName));
642
- const directText = inlineNodes.flatMap((child) => textWithMarks(child));
643
- if (directText.length)
644
- children.push({ type: "paragraph", id: createNodeId(), children: directText });
645
- // blockTags already lists "div"/"figure" as block-worthy, but parseBlock
646
- // itself has no case for either - parseBlockList is what actually
647
- // unwraps them (rather than swallowing their content into one opaque
648
- // `unknown` node) instead of parsing them directly.
649
- children.push(...parseBlockList(elementChildren(node).filter((child) => blockTags.includes(child.tagName || ""))));
658
+ const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
659
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
650
660
  if (!children.length)
651
661
  children.push({ type: "paragraph", id: createNodeId(), children: [] });
652
662
  return { type: "list_item", id: generatedId(node, "item"), ...(Object.keys(attrs).length ? { attrs } : {}), children };
@@ -659,24 +669,128 @@ const parseBlock = (node) => {
659
669
  return null;
660
670
  };
661
671
  /**
662
- * A thin wrapper around parseBlock for "list of block-level children"
663
- * call sites: a transparent container (TRANSPARENT_CONTAINER_TAGS) is
664
- * recursed into and its own children spliced in flat, rather than parsed
665
- * as one opaque node - a div wrapping N real blocks (a table, N
666
- * paragraphs, or nothing at all) produces exactly those N blocks instead
667
- * of a single `unknown` placeholder that hides all of them.
672
+ * Generic inline-formatting tags recognized by textWithMarks. Used by
673
+ * parseMixedBlockContent to decide when a bare (non-<p>-wrapped) element
674
+ * sitting at block level should be reinterpreted as inline text instead of
675
+ * left as parseBlock's own "unknown" round-trip-preserving placeholder.
676
+ * Deliberately narrow: an arbitrary/custom element (e.g. a host's own safe
677
+ * custom widget surviving the clipboard security boundary, see
678
+ * clipboard/pipeline.test.ts's "preserves a sanitized safe custom element
679
+ * as an unknown node") is NOT in this list, so it keeps going through the
680
+ * exact same "unknown" fallback as before - only tags that are
681
+ * unambiguously plain inline formatting ever get reinterpreted as text.
668
682
  */
669
- const parseBlockList = (nodes) => nodes.flatMap((node) => {
670
- if (TRANSPARENT_CONTAINER_TAGS.includes(node.tagName || ""))
671
- return parseBlockList(elementChildren(node));
672
- const parsed = parseBlock(node);
673
- return parsed ? [parsed] : [];
674
- });
683
+ const GENERIC_INLINE_TAGS = ["span", "strong", "b", "em", "i", "u", "s", "strike", "del", "code", "sup", "sub", "a", "br"];
684
+ /**
685
+ * Parses a mixed sequence of block and inline DOM children in place. A
686
+ * transparent container (TRANSPARENT_CONTAINER_TAGS) is recursed into and
687
+ * its own children spliced in flat, rather than parsed as one opaque node -
688
+ * a div wrapping N real blocks (a table, N paragraphs, or nothing at all)
689
+ * produces exactly those N blocks instead of a single `unknown` placeholder
690
+ * that hides all of them.
691
+ *
692
+ * Real-world exports (Sootr among them - both the document root and inside
693
+ * a <blockquote>/<td>/<li>) routinely put inline content - bare <span>
694
+ * wrapper runs, plain text, stray <br> - directly alongside real block
695
+ * children, with no wrapping <p>. Each *run* of consecutive non-block-like
696
+ * children is grouped into its own synthetic paragraph in place, preserving
697
+ * both the order of the surrounding real blocks and which inline runs were
698
+ * actually adjacent in the source. An earlier version of this function
699
+ * collected every inline child across the whole container into ONE
700
+ * paragraph and prepended it - correct only when inline content never
701
+ * appears between two blocks. Real legacy content interleaves several bare
702
+ * <span> lines between a <blockquote>, images, and paragraphs; merging them
703
+ * all into one paragraph at the front silently reordered content and
704
+ * mashed together lines that were never adjacent, and any other stray
705
+ * generic inline tag (e.g. a lone <b>) still fell through to the generic
706
+ * unrecognized-tag fallback and rendered as "[Unsupported: b]" wherever no
707
+ * caller-specific inline handling existed for it (previously: the document
708
+ * root, which had none at all).
709
+ *
710
+ * "a" is the one GENERIC_INLINE_TAGS entry with a real block-level meaning
711
+ * too - parseBlock's own "<a> wrapping only an <img>" case unwraps to a
712
+ * block image - so it's tried as a block first and only folded into the
713
+ * inline run if that doesn't apply.
714
+ */
715
+ const parseMixedBlockContent = (nodes, blockTags) => {
716
+ const result = [];
717
+ let inlineRun = [];
718
+ const flushInlineRun = () => {
719
+ if (!inlineRun.length)
720
+ return;
721
+ const children = inlineRun.flatMap((node) => textWithMarks(node));
722
+ inlineRun = [];
723
+ // A whitespace-only text node (a newline/indentation between block-level
724
+ // tags in pretty-printed source HTML - e.g. Word/Excel's real clipboard
725
+ // HTML) is insignificant, exactly like a browser's own whitespace
726
+ // collapsing between block elements - it must not produce its own
727
+ // spurious empty-looking paragraph. A run with any real text or any
728
+ // non-text child (an atom, a <br>) is kept in full, including any
729
+ // whitespace mixed into it (e.g. a genuine word-separating space).
730
+ const hasContent = children.some((child) => !isTextNode(child) || child.text.trim() !== "");
731
+ if (hasContent)
732
+ result.push({ type: "paragraph", id: createNodeId(), children });
733
+ };
734
+ const pushBlock = (node) => {
735
+ flushInlineRun();
736
+ const parsed = parseBlock(node);
737
+ if (parsed)
738
+ result.push(parsed);
739
+ };
740
+ nodes.forEach((node) => {
741
+ // Matches textWithMarks's own first check - a UI-only element (e.g. a
742
+ // checklist item's check-control <button>) has no tag this function
743
+ // would otherwise recognize as block-like, and previously reached
744
+ // parseBlock's generic "unknown" fallback instead of being silently
745
+ // dropped like every other reparse path already does for these.
746
+ if (isEditorUiNode(node))
747
+ return;
748
+ const tag = node.tagName;
749
+ if (!tag) {
750
+ inlineRun.push(node);
751
+ return;
752
+ }
753
+ // A bare third-party <div> (no data-smart-type) is genuinely
754
+ // meaningless wrapping and should be unwrapped - but this app's own
755
+ // round-tripped div-tagged atoms (block_formula, page_break; see
756
+ // atomToHtml) carry that marker precisely so they're recognized as
757
+ // opaque nodes, not content to flatten. Without this check, exporting
758
+ // and reimporting a block_formula (or page_break) silently deleted it
759
+ // outright: parseBlock never even saw the node, since this recursion
760
+ // handed off its (empty) childNodes instead - confirmed directly via
761
+ // `serializeCanonicalListHtml` -> `parseCanonicalListHtml` on a
762
+ // document containing nothing but a block_formula, which returned zero
763
+ // children back.
764
+ if (TRANSPARENT_CONTAINER_TAGS.includes(tag) && !attr(node, "data-smart-type")) {
765
+ flushInlineRun();
766
+ result.push(...parseMixedBlockContent(node.childNodes || [], blockTags));
767
+ return;
768
+ }
769
+ if (blockTags.includes(tag))
770
+ return pushBlock(node);
771
+ if (GENERIC_INLINE_TAGS.includes(tag)) {
772
+ if (tag === "a") {
773
+ const parsed = parseBlock(node);
774
+ if (parsed && parsed.type !== "unknown") {
775
+ flushInlineRun();
776
+ result.push(parsed);
777
+ return;
778
+ }
779
+ }
780
+ inlineRun.push(node);
781
+ return;
782
+ }
783
+ pushBlock(node);
784
+ });
785
+ flushInlineRun();
786
+ return result;
787
+ };
675
788
  export const parseCanonicalListHtml = (html) => {
676
789
  const fragment = parseFragment(html);
677
790
  const wrapper = elementChildren(fragment).find((node) => attr(node, "data-smart-document") === "true");
678
791
  const source = wrapper || fragment;
679
- const children = parseBlockList(elementChildren(source));
792
+ const rootBlockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table", "hr", "img", "video", "audio"];
793
+ const children = parseMixedBlockContent(source.childNodes || [], rootBlockTags);
680
794
  return { type: "doc", id: attr(source, "data-smart-id") || createNodeId(), children: children.length ? children : [{ type: "paragraph", id: createNodeId(), children: [] }] };
681
795
  };
682
796
  const plainText = (node) => (node.children || []).map((child) => isTextNode(child) ? child.text : child.type === "hard_break" ? "\n" : "").join("");
@@ -717,7 +831,7 @@ const markdownList = (list, depth) => (list.children || []).flatMap((child, inde
717
831
  return [line, ...continuation, ...nested];
718
832
  });
719
833
  const markdownBlock = (node) => {
720
- if (["block_image", "block_formula", "video", "audio", "divider"].includes(node.type))
834
+ if (["block_image", "block_formula", "video", "audio", "divider", "page_break"].includes(node.type))
721
835
  return [atomToMarkdown(node)];
722
836
  if (node.type === "list")
723
837
  return markdownList(node, 0);
@@ -27,7 +27,7 @@ const renderFormulaInto = (element, source) => {
27
27
  element.textContent = source;
28
28
  }
29
29
  };
30
- const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider"]);
30
+ const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider", "page_break"]);
31
31
  /**
32
32
  * atom/lifecycle.ts writes a specific upload-failure reason to
33
33
  * attrs.error (the host MediaProvider's own message, or a generic
@@ -117,6 +117,13 @@ export class FoundationSubtreeRenderer {
117
117
  element.style.marginInlineStart = `${Number(node.attrs.indentLevel) * 2}em`;
118
118
  else if (element.style.marginInlineStart)
119
119
  element.style.removeProperty("margin-inline-start");
120
+ // Unitless, matching how the value is stored and validated (schema.ts's
121
+ // lineHeightAttr) - absence means "no override", the browser/font's own
122
+ // natural line-height, not a forced "1".
123
+ if (node.attrs?.lineHeight)
124
+ element.style.lineHeight = String(node.attrs.lineHeight);
125
+ else if (element.style.lineHeight)
126
+ element.style.removeProperty("line-height");
120
127
  if (node.type === "code_block") {
121
128
  const language = typeof node.attrs?.language === "string" && node.attrs.language.trim()
122
129
  ? node.attrs.language.trim()
@@ -527,7 +534,7 @@ export class FoundationSubtreeRenderer {
527
534
  * the state through the normal diff path.
528
535
  */
529
536
  installMediaDiagnostics(element, node) {
530
- if (!atomTypes.has(node.type) || node.type === "formula" || node.type === "block_formula" || node.type === "divider")
537
+ if (!atomTypes.has(node.type) || node.type === "formula" || node.type === "block_formula" || node.type === "divider" || node.type === "page_break")
531
538
  return;
532
539
  if (element.hasAttribute("data-smart-media-events"))
533
540
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-core",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Framework-agnostic document model and editing primitives for Smart RTE",
5
5
  "repository": {
6
6
  "type": "git",