fix(html): fold inline-flow blocks into one styled snapshot text node

A block whose children are all inline (text plus <a>/<code>/<span>) was
captured as one node per inline child, and a wrapped run's rect was the
union of its line boxes anchored at the block's left edge — so
consecutive runs shared an origin and painted on top of each other
(the "Tehnegindearing paints..." smear in rich paragraphs and tables).

Fold an inline-formatting context into a single text node positioned
once at the inline content's own box, carrying per-run styling (link
colour/underline/href, code monospace, bold/italic) as segments with
CSS whitespace collapsing across inline boundaries. Single-<code>-only
cells are left unfolded so their pill background survives.

Captures from the current extension (no segments) still import as plain
text. Resolves the follow-up noted in da83157b7.
This commit is contained in:
Kayshen-X 2026-08-04 22:42:31 +08:00
parent 3e52873fca
commit 72792315e9
5 changed files with 548 additions and 1 deletions

View file

@ -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 `<a>` / `<code>` /
// `<span>` 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 `<a>` 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
// `<code>` 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;

View file

@ -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;

View file

@ -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 + `<a>` + `<code>`) 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 `<span>` 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"
);
}

View file

@ -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 `<a>` / `<code>` / `<span>`) 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<String, Value>, 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::<u32>().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-`<span>` 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

View file

@ -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 `<a>` / `<code>` /
// `<span>` 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 `<a>` 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
// `<code>` 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;