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;