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

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-beta.3
4
+
5
+ - 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.
6
+
7
+ ## 1.0.0-beta.2
8
+
9
+ - Add an opt-in `renderFormulaHtml` option to `serializeCanonicalListHtml` that bakes real KaTeX-rendered HTML into exported formula elements, instead of leaving an empty placeholder — for consumers (e.g. a read-only preview) that display this HTML directly without also running KaTeX against it themselves. Off by default; no change for existing consumers.
10
+
3
11
  ## 1.0.0-beta.1
4
12
 
5
13
  **BREAKING CHANGE.** This is the first release built on the canonical, schema-driven document model. Every published version through 0.2.1 was built on the older discriminated-union model; none of the work below has shipped before.
@@ -1,6 +1,9 @@
1
+ import "katex/contrib/mhchem";
1
2
  import type { SmartElementNode } from "../types.js";
2
- export declare const atomToHtml: (node: SmartElementNode) => string;
3
- export declare const atomFromHtmlElement: (element: Element) => SmartElementNode | null;
3
+ export declare const atomToHtml: (node: SmartElementNode, options?: {
4
+ renderFormulaHtml?: boolean;
5
+ }) => string;
6
+ export declare const atomFromHtmlElement: (rawElement: Element) => SmartElementNode | null;
4
7
  export declare const atomToMarkdown: (node: SmartElementNode) => string;
5
8
  export interface AtomDocxRun {
6
9
  readonly kind: "image" | "text";
@@ -1,16 +1,61 @@
1
+ import katex from "katex";
2
+ // Registers \ce{...} chemistry-equation notation on the shared katex module
3
+ // instance, matching surface/renderer.ts's own live-render import - a
4
+ // formula baked here via renderFormulaHtml must support the exact same
5
+ // notation the live editor does, or a chemistry entry would render live but
6
+ // fall back to plain text in the exported/baked HTML.
7
+ import "katex/contrib/mhchem";
1
8
  import { createNodeId } from "../identity.js";
9
+ import { sanitizeLinkHref, sanitizeLinkTarget } from "../security/urlPolicy.js";
2
10
  import { sanitizeAtomSource } from "./security.js";
3
11
  const escape = (value) => String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
12
  const attr = (name, value) => value === undefined ? "" : ` ${name}="${escape(value)}"`;
5
13
  const dimensions = (node) => `${attr("width", node.attrs?.width)}${attr("height", node.attrs?.height)}`;
6
- export const atomToHtml = (node) => {
14
+ // Mirrors surface/renderer.ts's KATEX_OPTIONS and try/catch-to-plain-text
15
+ // fallback exactly - a formula that fails to render live must fail the same
16
+ // way when baked into exported HTML, not throw and abort the whole export.
17
+ const KATEX_OPTIONS = { trust: false, strict: "error" };
18
+ const renderFormulaToHtml = (source) => {
19
+ try {
20
+ return katex.renderToString(source, KATEX_OPTIONS);
21
+ }
22
+ catch {
23
+ return escape(source);
24
+ }
25
+ };
26
+ export const atomToHtml = (node, options) => {
7
27
  if (node.type === "image" || node.type === "block_image") {
8
28
  const src = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: "image", allowBlobPreview: node.attrs?.status === "pending" }) || "";
9
- return `<img data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" src="${escape(src)}" alt="${escape(node.attrs?.alt)}"${dimensions(node)}${attr("data-smart-status", node.attrs?.status || "ready")}${node.attrs?.decorative === true ? ' data-smart-decorative="true"' : ""}${attr("data-smart-align", node.attrs?.align)}>`;
29
+ // Corner radius and license fields are data-attributes only (round-trip
30
+ // fidelity), matching this same function's existing data-smart-align -
31
+ // a raw HTML consumer displaying this string directly without also
32
+ // running the live surface renderer's own style logic against it
33
+ // wouldn't see visual rounding either way, same as align today; that's
34
+ // a deliberate, pre-existing scope boundary this doesn't change.
35
+ const img = `<img data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" src="${escape(src)}" alt="${escape(node.attrs?.alt)}"${dimensions(node)}${attr("data-smart-status", node.attrs?.status || "ready")}${node.attrs?.decorative === true ? ' data-smart-decorative="true"' : ""}${attr("data-smart-align", node.attrs?.align)}${attr("data-smart-radius", node.attrs?.borderRadius)}${attr("data-smart-license-description", node.attrs?.licenseDescription)}${attr("data-smart-license-source-url", node.attrs?.licenseSourceUrl)}${attr("data-smart-license-type", node.attrs?.licenseType)}${attr("data-smart-license-version", node.attrs?.licenseVersion)}${attr("data-smart-license-attribution", node.attrs?.licenseAttribution)}${attr("data-smart-href", node.attrs?.href)}${attr("data-smart-target", node.attrs?.target)}>`;
36
+ // A real <a> wrapper, unlike the live surface renderer's plain data
37
+ // attributes on the bare <img> - see surface/renderer.ts's own
38
+ // reasoning (stable-element-per-id constraint that doesn't apply to
39
+ // this string-based export) and modelDom.ts's identical choice for the
40
+ // one-shot clipboard/print DOM builder.
41
+ const linkHref = sanitizeLinkHref(typeof node.attrs?.href === "string" ? node.attrs.href : undefined);
42
+ if (!linkHref)
43
+ return img;
44
+ const target = sanitizeLinkTarget(typeof node.attrs?.target === "string" ? node.attrs.target : undefined);
45
+ return `<a href="${escape(linkHref)}"${target ? ` target="${escape(target)}"` : ""}>${img}</a>`;
10
46
  }
11
47
  if (node.type === "formula" || node.type === "block_formula") {
12
48
  const tag = node.type === "formula" ? "span" : "div";
13
- return `<${tag} data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" data-smart-notation="${escape(node.attrs?.notation || "latex")}" data-smart-formula="${escape(node.attrs?.source)}" role="math" aria-label="${escape(`Mathematical formula: ${String(node.attrs?.source || "")}`)}"></${tag}>`;
49
+ const source = String(node.attrs?.source || "");
50
+ // The rendered KaTeX HTML is purely a self-contained visual convenience
51
+ // for consumers that display this HTML string directly without also
52
+ // running katex.render() against it themselves (docs/bugs/
53
+ // formula-not-rendered-in-static-html-consumers.md) - data-smart-formula
54
+ // stays the single source of truth for round-tripping back into the
55
+ // model (list/formats.ts's parser reads the attribute, never this
56
+ // inner content), so baking this in can never desync source vs. render.
57
+ const inner = options?.renderFormulaHtml ? renderFormulaToHtml(source) : "";
58
+ return `<${tag} data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" data-smart-notation="${escape(node.attrs?.notation || "latex")}" data-smart-formula="${escape(source)}" role="math" aria-label="${escape(`Mathematical formula: ${source}`)}">${inner}</${tag}>`;
14
59
  }
15
60
  if (node.type === "video" || node.type === "audio") {
16
61
  const src = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: node.type }) || "";
@@ -20,7 +65,14 @@ export const atomToHtml = (node) => {
20
65
  return `<hr data-smart-id="${escape(node.id)}" data-smart-type="divider">`;
21
66
  throw new Error(`Unsupported atom type "${node.type}".`);
22
67
  };
23
- export const atomFromHtmlElement = (element) => {
68
+ export const atomFromHtmlElement = (rawElement) => {
69
+ // atomToHtml's own inverse - a linked image now wraps in a real <a>
70
+ // (needed so the link survives outside a live editor at all, see
71
+ // atomToHtml's own doc comment); unwrap to the actual atom element
72
+ // (which still carries every attribute, including data-smart-href,
73
+ // directly) before parsing, matching what a single element's own
74
+ // attributes already fully describe.
75
+ const element = rawElement.tagName === "A" && rawElement.children.length === 1 ? rawElement.children[0] : rawElement;
24
76
  const declared = element.getAttribute("data-smart-type");
25
77
  const type = declared || (element.tagName === "IMG" ? "image" : element.tagName.toLowerCase());
26
78
  const id = element.getAttribute("data-smart-id") || createNodeId();
@@ -29,7 +81,21 @@ export const atomFromHtmlElement = (element) => {
29
81
  const src = sanitizeAtomSource(element.getAttribute("src"), { kind: "image" });
30
82
  if (!src)
31
83
  return null;
32
- return { type, id, attrs: { src, alt: element.getAttribute("alt") || "", status: element.getAttribute("data-smart-status") || "ready", ...(element.getAttribute("data-smart-decorative") === "true" ? { decorative: true } : {}), ...(element.getAttribute("data-smart-align") ? { align: element.getAttribute("data-smart-align") } : {}), ...(number("width") ? { width: number("width") } : {}), ...(number("height") ? { height: number("height") } : {}) } };
84
+ const radius = Number(element.getAttribute("data-smart-radius"));
85
+ return { type, id, attrs: {
86
+ src, alt: element.getAttribute("alt") || "", status: element.getAttribute("data-smart-status") || "ready",
87
+ ...(element.getAttribute("data-smart-decorative") === "true" ? { decorative: true } : {}),
88
+ ...(element.getAttribute("data-smart-align") ? { align: element.getAttribute("data-smart-align") } : {}),
89
+ ...(number("width") ? { width: number("width") } : {}), ...(number("height") ? { height: number("height") } : {}),
90
+ ...(Number.isFinite(radius) && radius > 0 ? { borderRadius: radius } : {}),
91
+ ...(element.getAttribute("data-smart-href") ? { href: element.getAttribute("data-smart-href") } : {}),
92
+ ...(element.getAttribute("data-smart-target") ? { target: element.getAttribute("data-smart-target") } : {}),
93
+ ...(element.getAttribute("data-smart-license-description") ? { licenseDescription: element.getAttribute("data-smart-license-description") } : {}),
94
+ ...(element.getAttribute("data-smart-license-source-url") ? { licenseSourceUrl: element.getAttribute("data-smart-license-source-url") } : {}),
95
+ ...(element.getAttribute("data-smart-license-type") ? { licenseType: element.getAttribute("data-smart-license-type") } : {}),
96
+ ...(element.getAttribute("data-smart-license-version") ? { licenseVersion: element.getAttribute("data-smart-license-version") } : {}),
97
+ ...(element.getAttribute("data-smart-license-attribution") ? { licenseAttribution: element.getAttribute("data-smart-license-attribution") } : {}),
98
+ } };
33
99
  }
34
100
  if (type === "formula" || type === "block_formula") {
35
101
  return { type, id, attrs: { source: element.getAttribute("data-smart-formula") || "", notation: element.getAttribute("data-smart-notation") === "mathml" ? "mathml" : "latex" } };
@@ -1,3 +1,4 @@
1
+ import { normalizeLinkInput } from "../security/urlPolicy.js";
1
2
  const optionalString = { validate: (value) => typeof value === "string" };
2
3
  const requiredString = { required: true, validate: (value) => typeof value === "string" };
3
4
  const dimension = { validate: (value) => Number.isFinite(value) && Number(value) > 0 && Number(value) <= 100000 };
@@ -6,6 +7,34 @@ const imageAttrs = {
6
7
  src: requiredString, alt: requiredString, width: dimension, height: dimension,
7
8
  status, uploadId: optionalString, error: optionalString, decorative: { validate: (value) => typeof value === "boolean" },
8
9
  align: { validate: (value) => value === "center" || value === "left" || value === "right" },
10
+ /**
11
+ * docs/bugs/media-details-old-editor-field-parity.md: the old editor's
12
+ * "Link"/"Target" fields have no equivalent yet - the `link` mark already
13
+ * has this exact href/target shape (marks/commands.ts), but marks are
14
+ * explicitly disallowed on every atom node (`marks: ""` below), so this
15
+ * is a dedicated pair of atom-level attrs instead of reusing the mark.
16
+ */
17
+ /** Same normalizeLinkInput validation the "link" mark's own schema uses (marks/schema.ts) - rejects at the command layer, not just sanitizing later at render/export time. */
18
+ href: { validate: (value) => typeof value === "string" && normalizeLinkInput(value).href !== null },
19
+ target: optionalString,
20
+ /** Corner rounding (px) - confirmed absent anywhere in this schema before now, unlike align/alt/width which already existed with no UI. */
21
+ borderRadius: { validate: (value) => Number.isFinite(value) && Number(value) >= 0 && Number(value) <= 1000 },
22
+ /**
23
+ * Mirrors MediaItem.license's own shape (packages/react/src/mediaProvider.ts)
24
+ * field-for-field where a real equivalent exists, so a library-sourced
25
+ * image's already-fetched license metadata can be copied straight across
26
+ * at insert time (CanonicalAuthorityEditor.tsx's selectFromMediaManager)
27
+ * instead of being read, shown in the library browser, and then silently
28
+ * discarded the way it was before this - the atom schema had nowhere to
29
+ * persist it. Renamed to this project's own vocabulary (the old editor's
30
+ * "Attribution text" ~ MediaItem.license.author, "License description" ~
31
+ * workName/licenseText) and kept independently user-editable, since a
32
+ * freshly uploaded image has no provider-supplied license data at all.
33
+ * `licenseVersion` has no MediaItem.license equivalent (its shape has no
34
+ * separate version field) - always user-entered, never auto-populated.
35
+ */
36
+ licenseDescription: optionalString, licenseSourceUrl: optionalString,
37
+ licenseType: optionalString, licenseVersion: optionalString, licenseAttribution: optionalString,
9
38
  };
10
39
  const formulaAttrs = {
11
40
  source: requiredString,
@@ -2,6 +2,7 @@ import type { SmartDocument } from "../types.js";
2
2
  export declare const serializeCanonicalListHtml: (document: SmartDocument, options?: {
3
3
  clean?: boolean;
4
4
  fragment?: boolean;
5
+ renderFormulaHtml?: boolean;
5
6
  }) => string;
6
7
  export declare const parseCanonicalListHtml: (html: string) => SmartDocument;
7
8
  /** Alignment and indent are unsupported in Markdown; block content survives semantically. */
@@ -20,12 +20,31 @@ const isEditorUiNode = (node) => attr(node, "data-smart-ui") !== undefined || at
20
20
  * parseBlockList below for how they're unwrapped instead of swallowed.
21
21
  */
22
22
  const TRANSPARENT_CONTAINER_TAGS = ["div", "section", "article", "figure"];
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). */
24
+ const imageStyleAndLicenseAttrs = (node) => {
25
+ const radius = Number(attr(node, "data-smart-radius"));
26
+ const licenseDescription = attr(node, "data-smart-license-description");
27
+ const licenseSourceUrl = attr(node, "data-smart-license-source-url");
28
+ const licenseType = attr(node, "data-smart-license-type");
29
+ const licenseVersion = attr(node, "data-smart-license-version");
30
+ const licenseAttribution = attr(node, "data-smart-license-attribution");
31
+ return {
32
+ ...(Number.isFinite(radius) && radius > 0 ? { borderRadius: radius } : {}),
33
+ ...(licenseDescription ? { licenseDescription } : {}),
34
+ ...(licenseSourceUrl ? { licenseSourceUrl } : {}),
35
+ ...(licenseType ? { licenseType } : {}),
36
+ ...(licenseVersion ? { licenseVersion } : {}),
37
+ ...(licenseAttribution ? { licenseAttribution } : {}),
38
+ };
39
+ };
40
+ /** See serializeCanonicalListHtml's own doc comment for why this is a module-scoped flag rather than a threaded parameter. */
41
+ let renderFormulaHtmlMode = false;
23
42
  const serializeInline = (node) => {
24
43
  if (!isTextNode(node)) {
25
44
  if (node.type === "hard_break")
26
45
  return `<br data-smart-id="${escapeHtml(node.id)}" data-smart-type="hard_break">`;
27
46
  if (["image", "formula"].includes(node.type))
28
- return atomToHtml(node);
47
+ return atomToHtml(node, { renderFormulaHtml: renderFormulaHtmlMode });
29
48
  const raw = node.attrs?.raw;
30
49
  if (typeof raw?.html === "string")
31
50
  return raw.html;
@@ -91,7 +110,7 @@ const serializeBlock = (node, includeIds, listDepth = 0, tableCellA11y) => {
91
110
  }
92
111
  const id = includeIds ? ` data-smart-id="${escapeHtml(node.id)}"` : "";
93
112
  if (["block_image", "block_formula", "video", "audio", "divider"].includes(node.type))
94
- return atomToHtml(node);
113
+ return atomToHtml(node, { renderFormulaHtml: renderFormulaHtmlMode });
95
114
  if (node.type === "paragraph" || node.type === "heading") {
96
115
  const tag = node.type === "heading" ? `h${String(node.attrs?.level || 1)}` : "p";
97
116
  return `<${tag}${id}${blockAttributes(node)}>${(node.children || []).map(serializeInline).join("")}</${tag}>`;
@@ -179,12 +198,28 @@ const serializeBlock = (node, includeIds, listDepth = 0, tableCellA11y) => {
179
198
  }
180
199
  return `<div${id}>${(node.children || []).map((child) => isTextNode(child) ? serializeInline(child) : serializeBlock(child, includeIds)).join("")}</div>`;
181
200
  };
182
- export const serializeCanonicalListHtml = (document, options = {}) => options.clean === true
183
- ? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, false)).join("")
184
- .replace(/\sdata-smart-id=(?:"[^"]*"|'[^']*')/g, "")
185
- : options.fragment === true
186
- ? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")
187
- : `<div data-smart-document="true" data-smart-id="${escapeHtml(document.id)}">${document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")}</div>`;
201
+ export const serializeCanonicalListHtml = (document, options = {}) => {
202
+ // A module-scoped flag, not a threaded parameter: serializeInline/
203
+ // serializeBlock recurse through dozens of call sites (lists, tables,
204
+ // blockquotes) - threading a new parameter through every one of them is
205
+ // exactly the kind of change that's easy to miss at one nested call site
206
+ // and silently break there. Safe here because serialization is
207
+ // synchronous, single-threaded, non-reentrant tree recursion - the flag
208
+ // is always restored in `finally` before this function ever returns.
209
+ const previous = renderFormulaHtmlMode;
210
+ renderFormulaHtmlMode = options.renderFormulaHtml === true;
211
+ try {
212
+ return options.clean === true
213
+ ? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, false)).join("")
214
+ .replace(/\sdata-smart-id=(?:"[^"]*"|'[^']*')/g, "")
215
+ : options.fragment === true
216
+ ? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")
217
+ : `<div data-smart-document="true" data-smart-id="${escapeHtml(document.id)}">${document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")}</div>`;
218
+ }
219
+ finally {
220
+ renderFormulaHtmlMode = previous;
221
+ }
222
+ };
188
223
  const textWithMarks = (node, inherited = []) => {
189
224
  if (isEditorUiNode(node))
190
225
  return [];
@@ -209,12 +244,27 @@ const textWithMarks = (node, inherited = []) => {
209
244
  // synchronous and the image hasn't loaded yet at this point.
210
245
  const width = parsePixelWidth(styleValue(node, "width")) ?? parsePixelWidth(attr(node, "width"));
211
246
  const height = parsePixelWidth(styleValue(node, "height")) ?? parsePixelWidth(attr(node, "height"));
247
+ // <a><img></a> (this project's own export - list/formats.ts's own
248
+ // serializeBlock/atom/formats.ts's atomToHtml wrap a linked image in a
249
+ // real anchor - or any real-world source doing the same): the link
250
+ // mark from that ancestor <a> arrives here via `inherited`, but
251
+ // SmartElementNode has no `marks` field at all (only SmartTextNode
252
+ // does) - without this, the href would be silently discarded the
253
+ // moment it reached this atomic-image branch. The live editor's own
254
+ // DOM instead encodes this as plain data-smart-href/-target
255
+ // attributes directly on the <img> (surface/renderer.ts) with no
256
+ // wrapping <a> at all, so both encodings are checked.
257
+ const inheritedLink = inherited.find((mark) => mark.type === "link");
258
+ const href = attr(node, "data-smart-href") || inheritedLink?.attrs?.href;
259
+ const target = attr(node, "data-smart-target") || inheritedLink?.attrs?.target;
212
260
  return [{ type: "image", id: generatedId(node, "image"), attrs: {
213
261
  src, alt: attr(node, "alt") || "", status: attr(node, "data-smart-status") || "ready",
214
262
  ...(attr(node, "data-smart-decorative") === "true" ? { decorative: true } : {}),
215
263
  ...(attr(node, "title") ? { title: attr(node, "title") } : {}),
216
264
  ...(attr(node, "data-smart-align") ? { align: attr(node, "data-smart-align") } : {}),
217
265
  ...(width !== null ? { width } : {}), ...(height !== null ? { height } : {}),
266
+ ...(href ? { href } : {}), ...(target ? { target } : {}),
267
+ ...imageStyleAndLicenseAttrs(node),
218
268
  } }];
219
269
  }
220
270
  }
@@ -357,6 +407,19 @@ const parseBlock = (node) => {
357
407
  src, alt: attr(node, "alt") || "", status: attr(node, "data-smart-status") || "ready",
358
408
  ...(attr(node, "data-smart-decorative") === "true" ? { decorative: true } : {}),
359
409
  ...(width !== null ? { width } : {}), ...(height !== null ? { height } : {}),
410
+ // Only from data-smart-href/-target directly on this element (our own
411
+ // round-tripped export, per this whole branch's own `declaredAtom`
412
+ // gate) - deliberately NOT also checking for a wrapping <a> the way
413
+ // the inline `image` atom's parsing does just above in textWithMarks.
414
+ // The bare-<img>-at-block-level fallback a few lines down has its own
415
+ // long-standing, evidence-based decision (a real Wikipedia "copy
416
+ // image" paste) to unwrap and drop an incidental <a> wrapper rather
417
+ // than treat it as a user-authored link - extending that to also
418
+ // read href here would silently reverse that decision for every
419
+ // third-party block-level image paste, not just this app's own.
420
+ ...(attr(node, "data-smart-href") ? { href: attr(node, "data-smart-href") } : {}),
421
+ ...(attr(node, "data-smart-target") ? { target: attr(node, "data-smart-target") } : {}),
422
+ ...imageStyleAndLicenseAttrs(node),
360
423
  } };
361
424
  }
362
425
  if (declaredAtom === "block_formula")
@@ -2,6 +2,7 @@ import { isTextNode } from "./identity.js";
2
2
  import { nodeAtPath } from "./positions.js";
3
3
  import { renderMarkedText } from "./marks/dom.js";
4
4
  import { sanitizeAtomSource } from "./atom/security.js";
5
+ import { sanitizeLinkHref, sanitizeLinkTarget } from "./security/urlPolicy.js";
5
6
  const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider"]);
6
7
  export const SMART_UI_ATTRIBUTE = "data-smart-ui";
7
8
  export const SMART_PROJECTION_ATTRIBUTE = "data-smart-projection";
@@ -93,6 +94,23 @@ const renderNode = (node, ownerDocument) => {
93
94
  if (source)
94
95
  element.setAttribute("src", source);
95
96
  element.setAttribute("alt", typeof node.attrs?.alt === "string" ? node.attrs.alt : "");
97
+ if (node.attrs?.borderRadius)
98
+ element.style.borderRadius = `${Number(node.attrs.borderRadius)}px`;
99
+ const linkHref = sanitizeLinkHref(typeof node.attrs?.href === "string" ? node.attrs.href : undefined);
100
+ if (linkHref) {
101
+ // A real <a> wrapper, unlike the live surface renderer's plain data
102
+ // attributes - this is a one-shot tree builder (no per-node element
103
+ // identity to preserve across renders), and clipboard/print output
104
+ // needs a genuine anchor for the link to survive outside this editor
105
+ // at all.
106
+ const link = ownerDocument.createElement("a");
107
+ link.setAttribute("href", linkHref);
108
+ const linkTarget = sanitizeLinkTarget(typeof node.attrs?.target === "string" ? node.attrs.target : undefined);
109
+ if (linkTarget)
110
+ link.setAttribute("target", linkTarget);
111
+ link.appendChild(element);
112
+ return link;
113
+ }
96
114
  }
97
115
  if (node.type === "formula" || node.type === "block_formula") {
98
116
  const source = String(node.attrs?.source || "");
@@ -405,6 +405,62 @@ export class FoundationSubtreeRenderer {
405
405
  element.style.float = imageAlign;
406
406
  element.style.margin = imageAlign === "left" ? "0 8px 8px 0" : "0 0 8px 8px";
407
407
  }
408
+ else {
409
+ // No align set - reset every property the two branches above can
410
+ // set, not just skip setting new ones. A pre-existing gap (found
411
+ // while verifying docs/bugs/media-details-old-editor-field-parity.md's
412
+ // new "clear align back to None" UI path): this renderer's other
413
+ // conditional style branches (e.g. borderRadius just below) always
414
+ // pair their "set" case with an explicit "else remove" case for
415
+ // exactly this reason - align's own else branches never existed
416
+ // before this UI made "align was set, then explicitly cleared" a
417
+ // real, reachable transition for the first time.
418
+ element.style.removeProperty("display");
419
+ element.style.removeProperty("float");
420
+ element.style.removeProperty("margin");
421
+ }
422
+ if (node.attrs?.borderRadius)
423
+ element.style.borderRadius = `${Number(node.attrs.borderRadius)}px`;
424
+ else
425
+ element.style.removeProperty("border-radius");
426
+ // Plain data attributes, not a wrapping <a> - this element's identity
427
+ // is stable across renders (see this class's own diffing contract);
428
+ // introducing a conditional DOM-structure change here for something
429
+ // input.ts's click handling already reads directly is unnecessary
430
+ // risk. atomToHtml/modelDom.ts DO wrap in a real <a> for the
431
+ // string/one-shot export paths, which have no such stability
432
+ // constraint and need a real anchor for the link to work outside
433
+ // this live editor at all.
434
+ if (typeof node.attrs?.href === "string" && node.attrs.href)
435
+ this.setAttribute(element, "data-smart-href", node.attrs.href, node.id);
436
+ else
437
+ this.removeAttribute(element, "data-smart-href", node.id);
438
+ if (typeof node.attrs?.target === "string" && node.attrs.target)
439
+ this.setAttribute(element, "data-smart-target", node.attrs.target, node.id);
440
+ else
441
+ this.removeAttribute(element, "data-smart-target", node.id);
442
+ // Mirrored as data attributes too, matching href/target above - a
443
+ // host inspecting the live DOM (or this project's own e2e suite)
444
+ // should be able to see this data the same way it can see href, not
445
+ // just find it by reading the model directly. Purely descriptive
446
+ // metadata, so unlike align/borderRadius there's no matching CSS
447
+ // side effect to apply here.
448
+ if (node.attrs?.borderRadius)
449
+ this.setAttribute(element, "data-smart-radius", String(node.attrs.borderRadius), node.id);
450
+ else
451
+ this.removeAttribute(element, "data-smart-radius", node.id);
452
+ [
453
+ ["licenseDescription", "data-smart-license-description"],
454
+ ["licenseSourceUrl", "data-smart-license-source-url"],
455
+ ["licenseType", "data-smart-license-type"],
456
+ ["licenseVersion", "data-smart-license-version"],
457
+ ["licenseAttribution", "data-smart-license-attribution"],
458
+ ].forEach(([key, attrName]) => {
459
+ if (typeof node.attrs?.[key] === "string" && node.attrs[key])
460
+ this.setAttribute(element, attrName, String(node.attrs[key]), node.id);
461
+ else
462
+ this.removeAttribute(element, attrName, node.id);
463
+ });
408
464
  }
409
465
  else if (node.type === "formula" || node.type === "block_formula") {
410
466
  const source = String(node.attrs?.source || "");
@@ -3,6 +3,7 @@ export declare const parseCanonicalTableHtml: (html: string) => SmartDocument;
3
3
  export declare const serializeCanonicalTableHtml: (document: SmartDocument, options?: {
4
4
  clean?: boolean;
5
5
  fragment?: boolean;
6
+ renderFormulaHtml?: boolean;
6
7
  }) => string;
7
8
  /** GFM is intentionally lossy: anchor content survives and covered coordinates are emitted empty. */
8
9
  export declare const serializeCanonicalTableMarkdown: (document: SmartDocument) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-core",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "Framework-agnostic document model and editing primitives for Smart RTE",
5
5
  "repository": {
6
6
  "type": "git",