diff --git a/crates/op-html/assets/snapshot-extractor.js b/crates/op-html/assets/snapshot-extractor.js index c9e532ba5..ca342fd85 100644 --- a/crates/op-html/assets/snapshot-extractor.js +++ b/crates/op-html/assets/snapshot-extractor.js @@ -78,6 +78,18 @@ "text-align", "white-space", ]; + // Per-run overrides carried on a folded inline block's `segments` (see + // `buildInlineText`). These are the properties an inline `` / `` / + // `` changes against its block: colour, the monospace family and + // smaller size of code, bold / italic, and link underlines. + var SEGMENT_STYLE_KEYS = [ + "color", + "font-family", + "font-size", + "font-weight", + "font-style", + "text-decoration-line", + ]; // Computed values that carry no information: the importer's own defaults // are identical, so emitting them only inflates the payload (they are the // majority of every element's style block on a real page). @@ -101,6 +113,7 @@ position: "static", "z-index": "auto", "white-space": "normal", + "text-decoration-line": "none", display: "block", }; @@ -608,6 +621,138 @@ return result; } + // Inline elements that fold into their block's text flow rather than + // becoming a positioned node of their own. Anything with its own box + // (inline-block, media, a shadow root) is deliberately absent. + var INLINE_FLOW_TAGS = { + a: true, code: true, span: true, b: true, strong: true, i: true, + em: true, u: true, s: true, small: true, mark: true, sub: true, + sup: true, abbr: true, cite: true, q: true, kbd: true, samp: true, + time: true, label: true, bdi: true, bdo: true, del: true, ins: true, + strike: true, big: true, tt: true, nobr: true, + }; + + // Text, an ignorable node, or an inline element whose subtree is inline all + // the way down — i.e. a node that joins a normal inline flow. + function isInlineFlow(node) { + if (node.nodeType === Node.TEXT_NODE) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return true; + var tag = tagOf(node); + if (SKIP_TAGS[tag]) return true; + if (MEDIA_TAGS[tag] || node.shadowRoot || !INLINE_FLOW_TAGS[tag]) return false; + if (window.getComputedStyle(node).display !== "inline") return false; + return Array.prototype.every.call(node.childNodes, isInlineFlow); + } + + // Does this element lay its children out as ONE run of flowing inline text + // mixing bare text with inline elements? Those are the blocks the per-child + // capture smeared — each wrapped child's rect was the union of its line + // boxes, so consecutive children stacked at the block's left edge. + function isInlineTextBlock(element, computed) { + var display = computed.display; + if ( + display !== "block" && + display !== "inline-block" && + display !== "list-item" && + display.indexOf("table-cell") === -1 + ) { + return false; + } + var sawInlineElement = false; + var sawText = false; + var kids = element.childNodes; + for (var index = 0; index < kids.length; index += 1) { + var kid = kids[index]; + if (!isInlineFlow(kid)) return false; + if (kid.nodeType === Node.ELEMENT_NODE && !SKIP_TAGS[tagOf(kid)]) { + sawInlineElement = true; + } else if (kid.nodeType === Node.TEXT_NODE && kid.textContent.trim()) { + sawText = true; + } + } + // Fold only mixed content — a pure-text block already captures as one + // node with the right box, so leave that untouched. + return sawInlineElement && (sawText || element.childNodes.length > 1); + } + + // The `href` of the nearest enclosing `` up to (and including) `root`. + function nearestHref(node, root) { + for (var element = node; element; element = element.parentElement) { + if (tagOf(element) === "a") { + var href = element.getAttribute("href"); + if (href) return href; + } + if (element === root) break; + } + return null; + } + + // Walk the inline text of `element` in document order into styled runs, + // collapsing whitespace across inline boundaries as CSS does: `\s+` → one + // space, a straddling boundary space → one, block-edge space dropped. + function inlineSegments(element) { + var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); + var segments = []; + var spaceOpen = true; + var node; + while ((node = walker.nextNode())) { + var text = (node.textContent || "").replace(/\s+/g, " "); + if (spaceOpen && text.charAt(0) === " ") text = text.slice(1); + if (!text) continue; + spaceOpen = text.charAt(text.length - 1) === " "; + var parent = node.parentElement || element; + var href = nearestHref(parent, element); + var styles = copyStyles(window.getComputedStyle(parent), SEGMENT_STYLE_KEYS); + var key = JSON.stringify(styles) + "" + (href || ""); + var previous = segments[segments.length - 1]; + if (previous && previous.key === key) { + previous.text += text; + } else { + segments.push({ text: text, styles: styles, href: href, key: key }); + } + } + if (segments.length) { + var last = segments[segments.length - 1]; + last.text = last.text.replace(/ $/, ""); + if (!last.text) segments.pop(); + } + return segments; + } + + // One folded text node for a whole inline formatting context. Positioned at + // the inline content's own box (a range over the element's contents excludes + // padding and spans every line) and wrapped there once — never one node per + // inline child stacked at the block origin. Inline box decorations (a + // `` pill background, a badge border) are the one thing it cannot + // carry and are dropped. + function buildInlineText(element, blockComputed) { + var range = document.createRange(); + range.selectNodeContents(element); + var rect = range.getBoundingClientRect(); + var lines = range.getClientRects().length; + if (typeof range.detach === "function") range.detach(); + if (rect.width < 0.5 || rect.height < 0.5 || !takeNode()) return null; + var segments = inlineSegments(element); + var text = ""; + for (var index = 0; index < segments.length; index += 1) { + text += segments[index].text; + } + if (!text.trim()) return null; + var emitted = segments.map(function (segment) { + var out = { text: segment.text, styles: segment.styles }; + if (segment.href) out.href = segment.href; + return out; + }); + return { + kind: "text", + rect: pageRect(rect), + text: text, + lines: lines || 1, + styles: copyStyles(blockComputed, TEXT_STYLE_KEYS), + segments: emitted, + }; + } + function buildElement(element) { var tag = tagOf(element); if (SKIP_TAGS[tag]) return null; @@ -618,6 +763,18 @@ return buildImage(element, computed, rect); } if (!takeNode()) return null; + // A block that lays its children out as one inline text flow collapses to + // a single positioned text node with styled runs — see `buildInlineText`. + if (isInlineTextBlock(element, computed)) { + var folded = buildInlineText(element, computed); + return { + kind: "element", + tag: tag, + rect: pageRect(rect), + styles: elementStyles(computed), + children: folded ? [folded] : [], + }; + } var children = []; var collect = function (child) { if (truncated) return; diff --git a/crates/op-html/src/snapshot.rs b/crates/op-html/src/snapshot.rs index 8b19b2fc5..2f506b31e 100644 --- a/crates/op-html/src/snapshot.rs +++ b/crates/op-html/src/snapshot.rs @@ -765,3 +765,7 @@ fn solid_fill(color: String) -> PenFill { #[cfg(test)] #[path = "snapshot_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "snapshot_inline_tests.rs"] +mod inline_tests; diff --git a/crates/op-html/src/snapshot_inline_tests.rs b/crates/op-html/src/snapshot_inline_tests.rs new file mode 100644 index 000000000..26d9063bd --- /dev/null +++ b/crates/op-html/src/snapshot_inline_tests.rs @@ -0,0 +1,160 @@ +//! Regression tests for folding an inline formatting context into one +//! positioned styled text node (the rich-inline-text overlap fix). + +use super::*; +use crate::HtmlImportOptions; +use jian_ops_schema::node::PenNode; + +/// The regression this fix targets: a paragraph whose children are all inline +/// (bare text + `` + ``) that wraps across several lines. The +/// extractor now folds it into ONE positioned text node carrying styled +/// `segments`, so the block imports as a single wrapped run instead of one node +/// per inline child stacked at the block origin. Before the fix each wrapped +/// child's rect was the union of its line boxes — a full-width, multi-line box +/// whose top-left was the block's left edge — so consecutive children shared an +/// origin and the text rendered as an overlapping smear. +#[test] +fn folded_inline_block_is_one_styled_text_node_without_overlap() { + // Two wrapped runs that, under the old per-child capture, carried the + // SAME rect origin (0, 0) — the exact shape that produced the smear. + let json = r#"{ + "version": 1, + "root": { + "kind": "element", "tag": "p", + "rect": { "x": 0, "y": 0, "w": 300, "h": 80 }, + "styles": { "font-size": "16px" }, + "children": [ + { "kind": "text", "rect": { "x": 0, "y": 0, "w": 300, "h": 80 }, + "lines": 4, + "text": "The rendering engine paints onto a Painter surface.", + "styles": { "font-size": "16px", "color": "rgb(31, 35, 40)" }, + "segments": [ + { "text": "The ", "styles": { "color": "rgb(31, 35, 40)" } }, + { "text": "rendering", "styles": { "color": "rgb(9, 105, 218)", + "text-decoration-line": "underline" }, "href": "https://example.com/j" }, + { "text": " engine paints onto a ", "styles": { "color": "rgb(31, 35, 40)" } }, + { "text": "Painter", "styles": { "font-family": "ui-monospace", + "font-size": "13.6px" } }, + { "text": " surface.", "styles": { "color": "rgb(31, 35, 40)" } } + ] } + ] + } + }"#; + let result = import_snapshot(json, &HtmlImportOptions::default()); + let PenNode::Frame(root) = &result.nodes[0] else { + panic!() + }; + let children = root.children.as_ref().unwrap(); + // ONE text node for the whole inline context — not a node per inline run. + assert_eq!( + children.len(), + 1, + "the block must fold to a single text node" + ); + let PenNode::Text(text) = &children[0] else { + panic!("folded inline content must be a text node") + }; + use jian_ops_schema::node::text::{TextContent, TextGrowth}; + use jian_ops_schema::sizing::SizingBehavior; + let TextContent::Styled(segments) = &text.content else { + panic!("mixed inline content must import as styled runs, not plain") + }; + assert_eq!(segments.len(), 5); + // The link run keeps its colour, underline, and href. + let link = &segments[1]; + assert_eq!(link.text, "rendering"); + assert_eq!(link.href.as_deref(), Some("https://example.com/j")); + assert_eq!(link.fill.as_deref(), Some("#0969da")); + assert_eq!(link.underline, Some(true)); + // The code run keeps its monospace family and smaller size. + let code = &segments[3]; + assert_eq!(code.text, "Painter"); + assert_eq!(code.font_family.as_deref(), Some("ui-monospace")); + assert!(code + .font_size + .is_some_and(|size| (size - 13.6).abs() < 0.01)); + // Multi-line → wrapped mode: keep the captured width, grow the height. + assert!(matches!(text.width, Some(SizingBehavior::Number(w)) if w == 300.0)); + assert_eq!(text.text_growth, Some(TextGrowth::FixedWidth)); +} + +/// Cross-version: a capture from the currently-installed extension carries no +/// `segments`, so each inline child is still its own text node. Those payloads +/// must keep importing unchanged — the folded shape is additive. +#[test] +fn text_node_without_segments_stays_plain() { + let json = r#"{ + "version": 1, + "root": { + "kind": "element", "tag": "p", + "rect": { "x": 0, "y": 0, "w": 300, "h": 20 }, + "children": [ + { "kind": "text", "rect": { "x": 0, "y": 0, "w": 120, "h": 20 }, + "text": "plain run", "lines": 1, "styles": {} } + ] + } + }"#; + let result = import_snapshot(json, &HtmlImportOptions::default()); + let PenNode::Frame(root) = &result.nodes[0] else { + panic!() + }; + let PenNode::Text(text) = &root.children.as_ref().unwrap()[0] else { + panic!("text run") + }; + use jian_ops_schema::node::text::TextContent; + assert_eq!(text.content, TextContent::Plain("plain run".to_string())); +} + +/// A single unstyled run (a lone `` with no overrides) reduces to plain +/// text — no need to pay for styled content the run does not use. +#[test] +fn single_trivial_segment_reduces_to_plain_text() { + let json = r#"{ + "version": 1, + "root": { + "kind": "element", "tag": "p", + "rect": { "x": 0, "y": 0, "w": 300, "h": 20 }, + "children": [ + { "kind": "text", "rect": { "x": 0, "y": 0, "w": 120, "h": 20 }, + "text": "just text", "lines": 1, "styles": {}, + "segments": [ { "text": "just text", "styles": {} } ] } + ] + } + }"#; + let result = import_snapshot(json, &HtmlImportOptions::default()); + let PenNode::Frame(root) = &result.nodes[0] else { + panic!() + }; + let PenNode::Text(text) = &root.children.as_ref().unwrap()[0] else { + panic!("text run") + }; + use jian_ops_schema::node::text::TextContent; + assert_eq!(text.content, TextContent::Plain("just text".to_string())); +} + +/// Source-level guards for the inline-fold contract (see +/// `folded_inline_block_is_one_styled_text_node_without_overlap`). The +/// extractor needs a live DOM, so the invariants are pinned where they are +/// written. +#[test] +fn snapshot_extractor_pins_its_inline_fold_contract() { + // A block that lays its children out as one inline flow folds to a single + // text node instead of a node per inline child. + assert!( + SNAPSHOT_EXTRACTOR_JS.contains("isInlineTextBlock(element, computed)"), + "inline blocks must be detected and folded" + ); + // The folded node is positioned at the inline content's own box (a range + // over the element's contents), so padding is excluded and every line box + // is counted — never one union box per child stacked at the block origin. + assert!( + SNAPSHOT_EXTRACTOR_JS.contains("range.selectNodeContents(element)"), + "the folded box must come from the inline content, not per-child rects" + ); + // Per-run styling (link colour + href, code's monospace family) rides on + // `segments`, so folding does not flatten the paragraph to one style. + assert!( + SNAPSHOT_EXTRACTOR_JS.contains("segments: emitted"), + "folded inline runs must carry their styled segments" + ); +} diff --git a/crates/op-html/src/snapshot_text.rs b/crates/op-html/src/snapshot_text.rs index 2a9dfcd73..f7727e7d6 100644 --- a/crates/op-html/src/snapshot_text.rs +++ b/crates/op-html/src/snapshot_text.rs @@ -23,6 +23,7 @@ use std::collections::BTreeMap; use jian_ops_schema::node::text::{FontStyleKind, FontWeight, TextContent, TextGrowth, TextNode}; use jian_ops_schema::node::PenNode; use jian_ops_schema::sizing::{SizeLimits, SizingBehavior}; +use jian_ops_schema::style::{FontStyleKind as SegmentFontStyle, StyledTextSegment}; use serde_json::{Map, Value}; use super::{parse_px, parse_text_align, solid_fill, Rect, SnapshotCtx}; @@ -120,7 +121,7 @@ impl SnapshotCtx<'_> { limits: sizing.limits, width: sizing.width, height: sizing.height, - content: TextContent::Plain(text), + content: styled_content(object, text), font_family: styles.get("font-family").cloned(), font_size: Some(font_size), font_weight, @@ -145,6 +146,74 @@ impl SnapshotCtx<'_> { } } +/// Build the text node's content from a folded inline block's `segments`. +/// +/// The extractor collapses a block whose children are all inline (bare text +/// plus `` / `` / ``) into ONE positioned text node carrying the +/// concatenated text as styled `segments`, so the run flows and wraps once +/// instead of each inline child stacking at the block origin (the overlap this +/// fixes). A payload with no `segments` (an older capture) or one that reduces +/// to a single unstyled run falls back to plain text. +fn styled_content(object: &Map, plain: String) -> TextContent { + let Some(items) = object.get("segments").and_then(Value::as_array) else { + return TextContent::Plain(plain); + }; + let mut segments = Vec::new(); + for item in items { + let Some(segment) = item.as_object() else { + continue; + }; + let Some(text) = segment.get("text").and_then(Value::as_str) else { + continue; + }; + if text.is_empty() { + continue; + } + let styles = segment.get("styles").and_then(Value::as_object); + let style = |key: &str| styles.and_then(|map| map.get(key)).and_then(Value::as_str); + let decoration = style("text-decoration-line").unwrap_or(""); + segments.push(StyledTextSegment { + text: text.to_string(), + font_family: style("font-family").map(str::to_string), + font_size: style("font-size") + .and_then(parse_px) + .map(|value| value as f32), + font_weight: style("font-weight").and_then(|value| value.parse::().ok()), + font_style: match style("font-style") { + Some("italic" | "oblique") => Some(SegmentFontStyle::Italic), + Some("normal") => Some(SegmentFontStyle::Normal), + _ => None, + }, + fill: style("color").and_then(parse_css_color), + underline: decoration.contains("underline").then_some(true), + strikethrough: decoration.contains("line-through").then_some(true), + href: segment + .get("href") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + // A lone run carrying no overrides is indistinguishable from plain text — + // keep the node simple so the common single-`` block does not pay for + // styled content it does not use. + let trivial = segments.len() <= 1 + && segments.iter().all(|segment| { + segment.font_family.is_none() + && segment.font_size.is_none() + && segment.font_weight.is_none() + && segment.font_style.is_none() + && segment.fill.is_none() + && segment.underline.is_none() + && segment.strikethrough.is_none() + && segment.href.is_none() + }); + if segments.is_empty() || trivial { + TextContent::Plain(plain) + } else { + TextContent::Styled(segments) + } +} + /// Line-box count recorded by the extractor. Payloads captured before the /// field existed report nothing; a single line is the safe reading, because /// hugging a run that did wrap only makes it one line wide instead of diff --git a/packages/op-chrome-extension/vendor/snapshot-extractor.js b/packages/op-chrome-extension/vendor/snapshot-extractor.js index c9e532ba5..ca342fd85 100644 --- a/packages/op-chrome-extension/vendor/snapshot-extractor.js +++ b/packages/op-chrome-extension/vendor/snapshot-extractor.js @@ -78,6 +78,18 @@ "text-align", "white-space", ]; + // Per-run overrides carried on a folded inline block's `segments` (see + // `buildInlineText`). These are the properties an inline `` / `` / + // `` changes against its block: colour, the monospace family and + // smaller size of code, bold / italic, and link underlines. + var SEGMENT_STYLE_KEYS = [ + "color", + "font-family", + "font-size", + "font-weight", + "font-style", + "text-decoration-line", + ]; // Computed values that carry no information: the importer's own defaults // are identical, so emitting them only inflates the payload (they are the // majority of every element's style block on a real page). @@ -101,6 +113,7 @@ position: "static", "z-index": "auto", "white-space": "normal", + "text-decoration-line": "none", display: "block", }; @@ -608,6 +621,138 @@ return result; } + // Inline elements that fold into their block's text flow rather than + // becoming a positioned node of their own. Anything with its own box + // (inline-block, media, a shadow root) is deliberately absent. + var INLINE_FLOW_TAGS = { + a: true, code: true, span: true, b: true, strong: true, i: true, + em: true, u: true, s: true, small: true, mark: true, sub: true, + sup: true, abbr: true, cite: true, q: true, kbd: true, samp: true, + time: true, label: true, bdi: true, bdo: true, del: true, ins: true, + strike: true, big: true, tt: true, nobr: true, + }; + + // Text, an ignorable node, or an inline element whose subtree is inline all + // the way down — i.e. a node that joins a normal inline flow. + function isInlineFlow(node) { + if (node.nodeType === Node.TEXT_NODE) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return true; + var tag = tagOf(node); + if (SKIP_TAGS[tag]) return true; + if (MEDIA_TAGS[tag] || node.shadowRoot || !INLINE_FLOW_TAGS[tag]) return false; + if (window.getComputedStyle(node).display !== "inline") return false; + return Array.prototype.every.call(node.childNodes, isInlineFlow); + } + + // Does this element lay its children out as ONE run of flowing inline text + // mixing bare text with inline elements? Those are the blocks the per-child + // capture smeared — each wrapped child's rect was the union of its line + // boxes, so consecutive children stacked at the block's left edge. + function isInlineTextBlock(element, computed) { + var display = computed.display; + if ( + display !== "block" && + display !== "inline-block" && + display !== "list-item" && + display.indexOf("table-cell") === -1 + ) { + return false; + } + var sawInlineElement = false; + var sawText = false; + var kids = element.childNodes; + for (var index = 0; index < kids.length; index += 1) { + var kid = kids[index]; + if (!isInlineFlow(kid)) return false; + if (kid.nodeType === Node.ELEMENT_NODE && !SKIP_TAGS[tagOf(kid)]) { + sawInlineElement = true; + } else if (kid.nodeType === Node.TEXT_NODE && kid.textContent.trim()) { + sawText = true; + } + } + // Fold only mixed content — a pure-text block already captures as one + // node with the right box, so leave that untouched. + return sawInlineElement && (sawText || element.childNodes.length > 1); + } + + // The `href` of the nearest enclosing `` up to (and including) `root`. + function nearestHref(node, root) { + for (var element = node; element; element = element.parentElement) { + if (tagOf(element) === "a") { + var href = element.getAttribute("href"); + if (href) return href; + } + if (element === root) break; + } + return null; + } + + // Walk the inline text of `element` in document order into styled runs, + // collapsing whitespace across inline boundaries as CSS does: `\s+` → one + // space, a straddling boundary space → one, block-edge space dropped. + function inlineSegments(element) { + var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); + var segments = []; + var spaceOpen = true; + var node; + while ((node = walker.nextNode())) { + var text = (node.textContent || "").replace(/\s+/g, " "); + if (spaceOpen && text.charAt(0) === " ") text = text.slice(1); + if (!text) continue; + spaceOpen = text.charAt(text.length - 1) === " "; + var parent = node.parentElement || element; + var href = nearestHref(parent, element); + var styles = copyStyles(window.getComputedStyle(parent), SEGMENT_STYLE_KEYS); + var key = JSON.stringify(styles) + "" + (href || ""); + var previous = segments[segments.length - 1]; + if (previous && previous.key === key) { + previous.text += text; + } else { + segments.push({ text: text, styles: styles, href: href, key: key }); + } + } + if (segments.length) { + var last = segments[segments.length - 1]; + last.text = last.text.replace(/ $/, ""); + if (!last.text) segments.pop(); + } + return segments; + } + + // One folded text node for a whole inline formatting context. Positioned at + // the inline content's own box (a range over the element's contents excludes + // padding and spans every line) and wrapped there once — never one node per + // inline child stacked at the block origin. Inline box decorations (a + // `` pill background, a badge border) are the one thing it cannot + // carry and are dropped. + function buildInlineText(element, blockComputed) { + var range = document.createRange(); + range.selectNodeContents(element); + var rect = range.getBoundingClientRect(); + var lines = range.getClientRects().length; + if (typeof range.detach === "function") range.detach(); + if (rect.width < 0.5 || rect.height < 0.5 || !takeNode()) return null; + var segments = inlineSegments(element); + var text = ""; + for (var index = 0; index < segments.length; index += 1) { + text += segments[index].text; + } + if (!text.trim()) return null; + var emitted = segments.map(function (segment) { + var out = { text: segment.text, styles: segment.styles }; + if (segment.href) out.href = segment.href; + return out; + }); + return { + kind: "text", + rect: pageRect(rect), + text: text, + lines: lines || 1, + styles: copyStyles(blockComputed, TEXT_STYLE_KEYS), + segments: emitted, + }; + } + function buildElement(element) { var tag = tagOf(element); if (SKIP_TAGS[tag]) return null; @@ -618,6 +763,18 @@ return buildImage(element, computed, rect); } if (!takeNode()) return null; + // A block that lays its children out as one inline text flow collapses to + // a single positioned text node with styled runs — see `buildInlineText`. + if (isInlineTextBlock(element, computed)) { + var folded = buildInlineText(element, computed); + return { + kind: "element", + tag: tag, + rect: pageRect(rect), + styles: elementStyles(computed), + children: folded ? [folded] : [], + }; + } var children = []; var collect = function (child) { if (truncated) return;