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 +11 -0
- package/dist/foundation/atom/declarations.js +4 -0
- package/dist/foundation/atom/formats.d.ts +33 -2
- package/dist/foundation/atom/formats.js +50 -2
- package/dist/foundation/atom/schema.js +9 -0
- package/dist/foundation/atom/types.d.ts +1 -1
- package/dist/foundation/block/formats.d.ts +2 -0
- package/dist/foundation/block/formats.js +2 -0
- package/dist/foundation/block/schema.js +13 -1
- package/dist/foundation/formats/docx/export.js +25 -0
- package/dist/foundation/formats/featureCodecs.d.ts +8 -7
- package/dist/foundation/formats/featureCodecs.js +10 -9
- package/dist/foundation/formats/fidelity.d.ts +1 -1
- package/dist/foundation/formats/fidelity.js +18 -0
- package/dist/foundation/formats/pdf/format.js +24 -1
- package/dist/foundation/list/formats.js +156 -42
- package/dist/foundation/surface/renderer.js +9 -2
- package/package.json +1 -1
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 `})`;
|
|
@@ -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
|
-
:
|
|
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: "
|
|
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
|
-
|
|
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
|
|
5
|
-
* gate
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* level or note
|
|
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
|
|
15
|
-
* get real wiring for all four formats
|
|
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
|
|
8
|
-
* gate
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* level or note
|
|
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
|
|
18
|
-
* get real wiring for all four formats
|
|
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
|
-
|
|
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
|
-
*
|
|
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>.
|
|
489
|
-
//
|
|
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
|
|
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
|
|
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
|
|
640
|
-
const
|
|
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
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* as
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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;
|