diff --git a/crates/typst-cnd/examples/inline_boxes_and_floats.typ b/crates/typst-cnd/examples/inline_boxes_and_floats.typ new file mode 100644 index 000000000000..cbee5d0b1b06 --- /dev/null +++ b/crates/typst-cnd/examples/inline_boxes_and_floats.typ @@ -0,0 +1,24 @@ +// Paragraphs that only exist because a fragment body was realized, and the +// three ways that can go wrong: an inline `box` duplicating its enclosing +// paragraph, a deferred float being mistaken for one, and page furniture or +// a rendered pool entry becoming prose. +#set document(title: "Inline boxes and floats", author: "typst-cnd") +#set page( + width: 280pt, + height: 120pt, + margin: 20pt, + foreground: rotate(45deg, text(fill: luma(210), size: 24pt)[DRAFT STAMP]), +) +#set heading(numbering: "1.") + += Target section + +A sentence with #box[see @sec], with #box[@smith2024], and with +#box[boxed#footnote[Note written inside a box.] tail] in it. +#place(bottom, float: true)[FLOATED BODY TEXT] +Filler prose after the float, long enough to push the float onto a page of +its own and to keep the two paragraphs apart. + += References + +#bibliography("refs.yml", title: none) diff --git a/crates/typst-cnd/examples/layout_containers.typ b/crates/typst-cnd/examples/layout_containers.typ new file mode 100644 index 000000000000..7e5d5722f499 --- /dev/null +++ b/crates/typst-cnd/examples/layout_containers.typ @@ -0,0 +1,47 @@ +// Text carried by layout-only containers: a `block`, a grid cell, a `place`, +// an inline `box`, and content produced by a user function. None of these +// wrappers is emitted as a node, so the paragraphs inside them are the only +// carrier of that text. + +#set document(title: "Layout containers", author: "typst-cnd") + +#set page( + paper: "a5", + margin: 1.5cm, + footer: context [RUNNING FOOTER · #counter(page).display()], +) + +#let section(kicker, title) = { + block(spacing: 0.9em)[#text(weight: "bold")[#upper(kicker)]] + block(below: 1.1em)[#text(size: 18pt)[#title]] +} + +#let card(title, items) = { + block(fill: luma(240), inset: 8pt, width: 100%)[ + #text(weight: "bold")[#upper(title)] + #v(0.6em) + #for it in items { + block(spacing: 0.6em)[#it] + } + ] +} + +#let chip(body) = box(inset: (x: 6pt, y: 3pt), stroke: 0.5pt)[#body] + += Layout containers + +#section("Kicker line", "Title inside a block") + +A paragraph with #chip[FIRST CHIP] and #chip[SECOND CHIP] inline. + +A paragraph where #chip[idem] repeats a word the sentence itself uses: idem. + +#grid( + columns: (1fr, 1fr), + card("Left card", ("Left item one", "Left item two")), + card("Right card", ("Right item one", "Right item two")), +) + +#place(bottom + left, block(width: 100%)[Placed punch line at the bottom.]) + +Hard break here: first line \ second line. diff --git a/crates/typst-cnd/src/bin/typst-cnd.rs b/crates/typst-cnd/src/bin/typst-cnd.rs index a1470b213efa..d02b7c38afb1 100644 --- a/crates/typst-cnd/src/bin/typst-cnd.rs +++ b/crates/typst-cnd/src/bin/typst-cnd.rs @@ -48,10 +48,7 @@ fn run() -> SourceResult<()> { fn compile_file(input: &std::path::Path, output: &std::path::Path) -> SourceResult<()> { let world = world::CndWorld::new(input).map_err(|err| { - eco_vec![error!( - Span::detached(), - "failed to initialize world: {err}" - )] + eco_vec![error!(Span::detached(), "failed to initialize world: {err}")] })?; let warned = compile::(&world); @@ -60,17 +57,11 @@ fn compile_file(input: &std::path::Path, output: &std::path::Path) -> SourceResu } let document = warned.output?; - let cnd = cnd_from_document( - &document, - world::source_info(&world), - world::built_at_now(), - ); + let cnd = + cnd_from_document(&document, world::source_info(&world), world::built_at_now()); let json = cnd_to_json(&cnd)?; std::fs::write(output, json).map_err(|err| { - eco_vec![error!( - Span::detached(), - "failed to write output file: {err}" - )] + eco_vec![error!(Span::detached(), "failed to write output file: {err}")] })?; Ok(()) diff --git a/crates/typst-cnd/src/cnd.rs b/crates/typst-cnd/src/cnd.rs index 83439258bf7c..f5d30b25ec27 100644 --- a/crates/typst-cnd/src/cnd.rs +++ b/crates/typst-cnd/src/cnd.rs @@ -16,7 +16,9 @@ use typst_library::Category; use typst_library::diag::SourceResult; use typst_library::engine::Engine; -use typst_library::foundations::{Args, Construct, Content, Dict, Module, NativeFunc, Scope, Str, Value}; +use typst_library::foundations::{ + Args, Construct, Content, Dict, Module, NativeFunc, Scope, Str, Value, +}; use typst_library::introspection::{State, StateUpdate}; use typst_library::model::TableElem; @@ -86,7 +88,8 @@ fn cnd_table(engine: &mut Engine, args: &mut Args) -> SourceResult { }; let state = metadata_state(); - let set_fn = cnd_set_content_kind::func().with(&mut Args::new(span, [Value::Str(kind)])); + let set_fn = + cnd_set_content_kind::func().with(&mut Args::new(span, [Value::Str(kind)])); let unset_fn = cnd_unset_content_kind::func(); let before = state.clone().update(span, StateUpdate::Func(set_fn)); diff --git a/crates/typst-cnd/src/document.rs b/crates/typst-cnd/src/document.rs index a216511333db..5c3965028481 100644 --- a/crates/typst-cnd/src/document.rs +++ b/crates/typst-cnd/src/document.rs @@ -9,10 +9,13 @@ use typst_library::introspection::Introspector; use typst_library::model::{Document, DocumentInfo}; use typst_syntax::Span; +use crate::emit::ancestry::Ancestry; use crate::emit::convert::{self, ConvertContext}; use crate::emit::{pools, refs}; use crate::location::LocationAssigner; -use crate::model::{BibEntry, CND_VERSION, Cnd, DocDate, DocMetadata, Footnote, SourceInfo}; +use crate::model::{ + BibEntry, CND_VERSION, Cnd, DocDate, DocMetadata, Footnote, SourceInfo, +}; /// A compiled CND document before JSON serialization. #[derive(Debug, Clone)] @@ -68,12 +71,18 @@ impl Output for CndDocument { let introspector = paged.introspector().clone(); let info = paged.info().clone(); + // Containment is read off the laid-out frames: `Introspector::query` + // returns a flat list, but the emit pipeline must know which located + // elements sit inside a paragraph or inside page furniture. + let ancestry = Ancestry::from_document(&paged, engine); + let mut ctx = realize_and_convert( engine, introspector.as_ref(), content, styles, &info, + &ancestry, )?; refs::rebuild_label_index(&mut ctx, introspector.as_ref()); convert::apply_metadata(&mut ctx); @@ -96,13 +105,7 @@ impl Output for CndDocument { let mut assigner = LocationAssigner::new(introspector.clone(), ctx.records); assigner.assign_all(&mut nodes); - Ok(Self { - info, - nodes, - bibliography, - footnotes, - introspector, - }) + Ok(Self { info, nodes, bibliography, footnotes, introspector }) } fn introspector(&self) -> &dyn Introspector { @@ -116,6 +119,7 @@ fn realize_and_convert( _content: &Content, styles: StyleChain, info: &DocumentInfo, + ancestry: &Ancestry, ) -> SourceResult { let mut ctx = ConvertContext::default(); crate::emit::convert::convert_from_introspector( @@ -123,6 +127,7 @@ fn realize_and_convert( introspector, styles, doc_lang_from_info(info), + ancestry, &mut ctx, )?; Ok(ctx) @@ -201,9 +206,6 @@ fn datetime_to_doc_date(dt: Datetime) -> DocDate { /// Serialize a CND to pretty JSON. pub fn cnd_to_json(cnd: &Cnd) -> SourceResult { serde_json::to_string_pretty(cnd).map_err(|err| { - eco_vec![error!( - Span::detached(), - "failed to serialize CND: {err}" - )] + eco_vec![error!(Span::detached(), "failed to serialize CND: {err}")] }) } diff --git a/crates/typst-cnd/src/emit/ancestry.rs b/crates/typst-cnd/src/emit/ancestry.rs new file mode 100644 index 000000000000..3ccefd7e03ba --- /dev/null +++ b/crates/typst-cnd/src/emit/ancestry.rs @@ -0,0 +1,269 @@ +//! Containment relationships between laid-out elements, read off the frames. +//! +//! The emit pipeline works from `Introspector::query`, which returns a flat +//! list of located elements with no parent/child information. That is fine as +//! long as every queried element is a standalone node, but it is not: with +//! [`Feature::CndSemantics`] enabled every fully-inline fragment body becomes +//! a real `ParElem`, including the body of an inline `box` sitting *inside* +//! another paragraph. Emitting both would duplicate the box's text. +//! +//! The laid-out frames carry the missing structure. Introspection tags appear +//! in them as balanced `Tag::Start` / `Tag::End` pairs, and visiting the frame +//! tree depth-first yields them in document order, so a stack reconstructs +//! exact ancestry — no source-range or text-prefix heuristics. +//! +//! [`Feature::CndSemantics`]: typst_library::Feature::CndSemantics + +use std::ops::ControlFlow; + +use rustc_hash::{FxHashMap, FxHashSet}; +use typst_library::WorldExt; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Element, NativeElement}; +use typst_library::introspection::{Location, Tag}; +use typst_library::layout::{Frame, FrameItem}; +use typst_library::math::EquationElem; +use typst_library::model::{ + BibliographyElem, EnumElem, FigureElem, FootnoteEntry, HeadingElem, ListElem, + OutlineElem, ParElem, QuoteElem, TableElem, TermsElem, +}; +use typst_library::pdf::ArtifactElem; +use typst_library::text::RawElem; +use typst_syntax::{FileId, Span}; + +/// Which of a laid-out element's ancestors matter to the emit pipeline. +#[derive(Debug, Default)] +pub struct Ancestry { + /// Paragraphs that are part of an enclosing paragraph's own content, + /// mapped to that paragraph. Emitting both would duplicate the text; the + /// enclosing one absorbs the nested one's footnote/citation/reference + /// edges instead. + covered_by_par: FxHashMap, + /// Elements that sit inside an `ArtifactElem` — page furniture (headers, + /// footers, page decorations), which Typst's accessibility model already + /// marks as "not document content". + in_artifact: FxHashSet, + /// Elements that sit inside a container the emit pipeline turns into a + /// node of its own — a list, a table, a code block, an equation. Such a + /// node renders its own children, so a paragraph found inside one is that + /// container's content seen a second time. + in_container: FxHashSet, +} + +impl Ancestry { + /// Reconstruct ancestry from a laid-out document's frames. + pub fn from_document(paged: &typst_layout::PagedDocument, engine: &Engine) -> Self { + let mut ancestry = Self::default(); + let mut stack: Vec = Vec::new(); + for page in paged.pages() { + walk(&page.frame, engine, &mut stack, &mut ancestry); + } + ancestry + } + + /// The paragraph this one is part of, if any. + /// + /// The free function of the same name below states the rule. + pub fn covering_par(&self, location: Location) -> Option { + self.covered_by_par.get(&location).copied() + } + + /// Whether `location` sits inside page furniture marked as a PDF artifact. + pub fn is_artifact(&self, location: Location) -> bool { + self.in_artifact.contains(&location) + } + + /// Whether `location` sits inside a container that is emitted as its own + /// node — a list item's marker and body, a table cell, the lines of a code + /// block, an equation's numbering. + pub fn is_in_emitted_container(&self, location: Location) -> bool { + self.in_container.contains(&location) + } +} + +/// Whether a paragraph laid out inside `elem` is a second copy of text the +/// document already carries elsewhere. Layout-only containers — a bare +/// `grid`, a `block`, a `place` — are deliberately absent: they produce no +/// node, so their paragraphs are the only carrier of that text. +/// +/// Two kinds qualify. Most are emitted as a node that renders its own +/// content, so a paragraph inside is that content seen twice. The outline is +/// the exception: it is emitted as nothing at all, and every entry repeats a +/// heading that is already a node of its own — navigation furniture. +fn is_emitted_container(elem: Element) -> bool { + elem == HeadingElem::ELEM + || elem == QuoteElem::ELEM + || elem == ListElem::ELEM + || elem == EnumElem::ELEM + || elem == TermsElem::ELEM + || elem == RawElem::ELEM + || elem == EquationElem::ELEM + || elem == FigureElem::ELEM + || elem == TableElem::ELEM + // A footnote's rendered entry at the bottom of the page: its text is + // carried by the `footnotes` pool, which every marker refers to. + || elem == FootnoteEntry::ELEM + // Likewise a rendered bibliography entry, whose text the + // `bibliography` pool carries. With the default title the entries are + // swallowed by the generated heading's source range; with + // `title: none` — a hand-written "= References" — nothing catches + // them and each entry leaks as two paragraphs. + || elem == BibliographyElem::ELEM + // Table of contents. + || elem == OutlineElem::ELEM +} + +/// A tag that is currently open while walking the frames. +struct Open { + location: Location, + /// The paragraph's own source range, against which a nested paragraph is + /// tested for being part of it. `None` for non-paragraphs and for a + /// paragraph with no usable span. + par_range: Option>, + is_artifact: bool, + is_container: bool, + /// Glyph runs drawn for this paragraph inside / outside artifact-marked + /// content. A paragraph all of whose glyphs are drawn inside an artifact + /// is page furniture, whichever way the two tags happen to nest. + glyphs_in_artifact: u32, + glyphs_outside_artifact: u32, +} + +fn walk(frame: &Frame, engine: &Engine, stack: &mut Vec, ancestry: &mut Ancestry) { + for (_, item) in frame.items() { + match item { + // Depth-first keeps the tag stream in document order. + FrameItem::Group(group) => walk(&group.frame, engine, stack, ancestry), + FrameItem::Tag(Tag::Start(inner, _)) => { + let Some(location) = inner.location() else { continue }; + let is_par = inner.elem() == ParElem::ELEM; + + let par_range = is_par.then(|| content_range(engine, inner)); + if let Some(range) = par_range + && let Some(parent) = covering_par(stack, range) + { + ancestry.covered_by_par.insert(location, parent); + } + if stack.iter().any(|open| open.is_artifact) { + ancestry.in_artifact.insert(location); + } + if stack.iter().any(|open| open.is_container) { + ancestry.in_container.insert(location); + } + + stack.push(Open { + location, + par_range, + is_artifact: inner.elem() == ArtifactElem::ELEM, + is_container: is_emitted_container(inner.elem()), + glyphs_in_artifact: 0, + glyphs_outside_artifact: 0, + }); + } + FrameItem::Tag(Tag::End(location, ..)) => { + // Pop to the matching frame. Tags are balanced in practice; + // popping by search rather than blindly keeps a stray end tag + // from unwinding unrelated ancestors. + if let Some(index) = + stack.iter().rposition(|open| open.location == *location) + { + for open in stack.drain(index..) { + if open.par_range.is_some() + && open.glyphs_outside_artifact == 0 + && open.glyphs_in_artifact > 0 + { + ancestry.in_artifact.insert(open.location); + } + } + } + } + FrameItem::Text(_) => { + let in_artifact = stack.iter().any(|open| open.is_artifact); + for open in stack.iter_mut().filter(|open| open.par_range.is_some()) { + if in_artifact { + open.glyphs_in_artifact += 1; + } else { + open.glyphs_outside_artifact += 1; + } + } + } + _ => {} + } + } +} + +/// A half-open byte range in one source file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SourceRange { + file: FileId, + start: usize, + end: usize, +} + +fn source_range(engine: &Engine, span: Span) -> Option { + let file = span.id()?; + let range = engine.world.range(span)?; + Some(SourceRange { file, start: range.start, end: range.end }) +} + +/// How far `content` reaches in the source. +/// +/// A `ParElem`'s own span covers only what it starts with — for +/// `Look at #box[…] please.` it is the seven bytes of `Look at` — so it +/// cannot be asked whether something is written inside the paragraph. The +/// union of the spans of everything the paragraph holds can. +/// +/// Spans from other files are ignored: content pulled in from a template or +/// a `#let` defined elsewhere would otherwise stretch the range across a file +/// it says nothing about. +fn content_range(engine: &Engine, content: &Content) -> Option { + let mut range = source_range(engine, content.span()); + let file = range.map(|r| r.file).or_else(|| content.span().id())?; + let _ = content.traverse(&mut |element| { + if let Some(found) = source_range(engine, element.span()) + && found.file == file + { + range = Some(match range { + Some(cur) => SourceRange { + file, + start: cur.start.min(found.start), + end: cur.end.max(found.end), + }, + None => found, + }); + } + ControlFlow::<()>::Continue(()) + }); + range +} + +/// The paragraph `range` is part of, if any. +/// +/// Two independent signals must agree, and each guards a different mistake. +/// +/// *Nesting in the laid-out frames* — the nearest enclosing open paragraph — +/// is what identifies an inline `box`: its body is realized into a paragraph +/// of its own, drawn inside the line of the paragraph that contains it. This +/// is deliberately not a text comparison: the box body is realized and the +/// enclosing paragraph's copy of it is not, so anything that only acquires +/// text when realized — a `@reference`'s "Section 1", a citation's marker, a +/// footnote's ordinal — appears in one and not the other, and a paragraph +/// holding `#box[see @fig]` would compare unequal and be emitted twice. +/// +/// *Source containment* is what keeps a float out. `#place(float: true)[…]` +/// written inside a paragraph is deferred to wherever it fits, and its tags +/// can land inside a *later* paragraph's still-open range — nesting alone +/// would delete it, which is content loss rather than duplication. A float +/// body's source range is never inside the range of the paragraph it lands +/// in: a block-level `place` ends the paragraph it was written in, so the +/// paragraph that ends up enclosing it in the frames always begins after it. +fn covering_par(stack: &[Open], range: Option) -> Option { + let range = range?; + let nearest = stack.iter().rev().find(|open| open.par_range.is_some())?; + let parent = nearest.par_range.flatten()?; + contains(parent, range).then_some(nearest.location) +} + +fn contains(outer: SourceRange, inner: SourceRange) -> bool { + outer.file == inner.file && inner.start >= outer.start && inner.end <= outer.end +} diff --git a/crates/typst-cnd/src/emit/code.rs b/crates/typst-cnd/src/emit/code.rs index c481173f800b..a44a545734b5 100644 --- a/crates/typst-cnd/src/emit/code.rs +++ b/crates/typst-cnd/src/emit/code.rs @@ -1,4 +1,3 @@ -use ecow::EcoString; use typst_library::engine::Engine; use typst_library::foundations::{Packed, StyleChain}; use typst_library::introspection::Introspector; @@ -16,7 +15,7 @@ pub fn convert( styles: StyleChain, ) -> typst_library::diag::SourceResult<(CodeNode, NodeRecord)> { let block = raw.block.get(styles); - let text: EcoString = extract_text(&raw.clone().pack()); + let text = extract_text(&raw.clone().pack()); let lang = raw.lang.get_cloned(styles).map(Into::into); let id = uuid::Uuid::new_v4(); diff --git a/crates/typst-cnd/src/emit/convert.rs b/crates/typst-cnd/src/emit/convert.rs index 2f1313374bf0..e43fdbc6b32c 100644 --- a/crates/typst-cnd/src/emit/convert.rs +++ b/crates/typst-cnd/src/emit/convert.rs @@ -4,20 +4,25 @@ use ecow::{EcoString, eco_vec}; use rustc_hash::FxHashSet; use typst_library::WorldExt; use typst_library::engine::Engine; -use typst_library::foundations::{Content, Label, NativeElement, Packed, Selector, StyleChain}; +use typst_library::foundations::{ + Content, Label, NativeElement, Packed, Selector, StyleChain, +}; use typst_library::introspection::{Introspector, Location, Tag, TagElem}; use typst_library::math::EquationElem; use typst_library::model::{ - CiteElem, EnumElem, EnumItem, FigureCaption, FigureElem, FootnoteElem, HeadingElem, ListElem, - ListItem, ParElem, QuoteElem, RefElem, Supplement, TableElem, TermsElem, + CiteElem, EnumElem, EnumItem, FigureCaption, FigureElem, FootnoteElem, HeadingElem, + ListElem, ListItem, ParElem, QuoteElem, RefElem, Supplement, TableElem, TermsElem, }; use typst_library::text::RawElem; use typst_library::visualize::ImageElem; use typst_syntax::{FileId, Span}; use uuid::Uuid; +use crate::emit::ancestry::Ancestry; use crate::emit::extract::{ExtractedMarker, MarkerKind}; -use crate::emit::{code, extract, figure, heading, list, math, paragraph, quote, reading_order, table}; +use crate::emit::{ + code, extract, figure, heading, list, math, paragraph, quote, reading_order, table, +}; use crate::model::{CndNode, HeadingNode}; /// A citation marker occurring in a node's content, captured for @@ -77,6 +82,25 @@ pub struct ConvertContext { } impl ConvertContext { + /// The records, ordered by node id. + /// + /// `records` is a hash map, whose iteration order is unspecified. Every + /// pass that builds edges out of it pushes them in the order it visits + /// the nodes, and those edges are then deduplicated first-wins — so the + /// result must not depend on the map's internal layout. + /// + /// This does *not* make a CND reproducible across runs: node ids are + /// `Uuid::new_v4()`, so the order is freshly arbitrary every time. It + /// buys one thing only — the order is a function of the ids, so two + /// passes over the same map agree, and an unrelated insertion cannot + /// silently reshuffle which edge wins. + pub fn records_sorted(&self) -> Vec<(Uuid, &NodeRecord)> { + let mut records: Vec<(Uuid, &NodeRecord)> = + self.records.iter().map(|(id, record)| (*id, record)).collect(); + records.sort_unstable_by_key(|(id, _)| *id); + records + } + pub fn register(&mut self, id: Uuid, record: NodeRecord) { if let Some(label) = record.label { self.label_to_id.insert(label, id); @@ -121,11 +145,7 @@ struct SourceRange { fn source_range(engine: &Engine, span: Span) -> Option { let file = span.id()?; let range = engine.world.range(span)?; - Some(SourceRange { - file, - start: range.start, - end: range.end, - }) + Some(SourceRange { file, start: range.start, end: range.end }) } fn range_contains(outer: SourceRange, inner: SourceRange) -> bool { @@ -136,7 +156,10 @@ fn is_strictly_nested(inner: SourceRange, outer: SourceRange) -> bool { range_contains(outer, inner) && inner != outer } -fn build_list_enum_ranges(engine: &Engine, introspector: &dyn Introspector) -> Vec { +fn build_list_enum_ranges( + engine: &Engine, + introspector: &dyn Introspector, +) -> Vec { let mut ranges = Vec::new(); for selector in [ListElem::ELEM.select(), EnumElem::ELEM.select()] { for elem in introspector.query(&selector) { @@ -173,7 +196,12 @@ fn build_inline_nested_list_enum_locations( continue; }; for item in &list.children { - collect_inline_list_enum_locations(engine, introspector, &item.body, &mut locations); + collect_inline_list_enum_locations( + engine, + introspector, + &item.body, + &mut locations, + ); } } @@ -182,7 +210,12 @@ fn build_inline_nested_list_enum_locations( continue; }; for item in &enum_.children { - collect_inline_list_enum_locations(engine, introspector, &item.body, &mut locations); + collect_inline_list_enum_locations( + engine, + introspector, + &item.body, + &mut locations, + ); } } @@ -204,7 +237,12 @@ fn collect_inline_list_enum_locations( } if let Some(list) = list.to_packed::() { for item in &list.children { - collect_inline_list_enum_locations(engine, introspector, &item.body, locations); + collect_inline_list_enum_locations( + engine, + introspector, + &item.body, + locations, + ); } } } @@ -217,7 +255,12 @@ fn collect_inline_list_enum_locations( } if let Some(enum_) = enum_.to_packed::() { for item in &enum_.children { - collect_inline_list_enum_locations(engine, introspector, &item.body, locations); + collect_inline_list_enum_locations( + engine, + introspector, + &item.body, + locations, + ); } } } @@ -246,10 +289,10 @@ fn is_nested_list_or_enum( list_enum_ranges: &[SourceRange], inline_nested_locations: &FxHashSet, ) -> bool { - if let Some(loc) = content.location() { - if inline_nested_locations.contains(&loc) { - return true; - } + if let Some(loc) = content.location() + && inline_nested_locations.contains(&loc) + { + return true; } if is_inline_nested_list_or_enum(engine, introspector, content) { return true; @@ -257,10 +300,7 @@ fn is_nested_list_or_enum( let Some(inner) = source_range(engine, content.span()) else { return false; }; - if list_enum_ranges - .iter() - .any(|outer| is_strictly_nested(inner, *outer)) - { + if list_enum_ranges.iter().any(|outer| is_strictly_nested(inner, *outer)) { return true; } is_list_or_enum_inside_item(engine, introspector, inner) @@ -300,7 +340,11 @@ fn is_inline_nested_list_or_enum( false } -fn inline_list_or_enum_in_body(engine: &Engine, body: &Content, target: SourceRange) -> bool { +fn inline_list_or_enum_in_body( + engine: &Engine, + body: &Content, + target: SourceRange, +) -> bool { for selector in [ListElem::ELEM.select(), EnumElem::ELEM.select()] { if let Some(found) = body.query_first_naive(&selector) { if source_range(engine, found.span()) == Some(target) { @@ -344,7 +388,10 @@ fn is_list_or_enum_inside_item( false } -fn build_skip_ranges(engine: &Engine, introspector: &dyn Introspector) -> Vec { +fn build_skip_ranges( + engine: &Engine, + introspector: &dyn Introspector, +) -> Vec { let selectors = [ QuoteElem::ELEM.select(), ListElem::ELEM.select(), @@ -376,13 +423,10 @@ fn should_skip_paragraph( skip_ranges: &[SourceRange], skip_texts: &FxHashSet, ) -> bool { - if let Some(par_range) = source_range(engine, content.span()) { - if skip_ranges - .iter() - .any(|outer| range_contains(*outer, par_range)) - { - return true; - } + if let Some(par_range) = source_range(engine, content.span()) + && skip_ranges.iter().any(|outer| range_contains(*outer, par_range)) + { + return true; } skip_texts.contains(&normalize_paragraph_key(&extract::extract_text(&par.body))) || paragraph_text_is_skipped( @@ -396,9 +440,17 @@ fn normalize_paragraph_key(text: &str) -> String { .filter(|ch| { !matches!( ch, - '\u{201c}' | '\u{201d}' | '\u{2018}' | '\u{2019}' | '"' - | '\u{00ab}' | '\u{00bb}' | '\u{2039}' | '\u{203a}' - | '\u{202f}' | '\u{a0}' + '\u{201c}' + | '\u{201d}' + | '\u{2018}' + | '\u{2019}' + | '"' + | '\u{00ab}' + | '\u{00bb}' + | '\u{2039}' + | '\u{203a}' + | '\u{202f}' + | '\u{a0}' ) }) .collect::() @@ -415,9 +467,9 @@ fn paragraph_text_is_skipped(key: &str, skip_texts: &FxHashSet) -> bool if skip_texts.contains(key) { return true; } - skip_texts.iter().any(|skip| { - skip.starts_with(key) || (key.len() >= 10 && key.starts_with(skip)) - }) + skip_texts + .iter() + .any(|skip| skip.starts_with(key) || (key.len() >= 10 && key.starts_with(skip))) } fn push_text_key(skip: &mut FxHashSet, text: &str) { @@ -491,22 +543,22 @@ fn build_skip_paragraph_texts(introspector: &dyn Introspector) -> FxHashSet, skip: &mut FxHashSet) { push_text_key(skip, &extract::extract_text(&item.body)); - if let Some(nested) = item.body.query_first_naive(&ListElem::ELEM.select()) { - if let Some(list) = nested.to_packed::() { - for child in &list.children { - collect_list_item_texts(child, skip); - } + if let Some(nested) = item.body.query_first_naive(&ListElem::ELEM.select()) + && let Some(list) = nested.to_packed::() + { + for child in &list.children { + collect_list_item_texts(child, skip); } } } fn collect_enum_item_texts(item: &Packed, skip: &mut FxHashSet) { push_text_key(skip, &extract::extract_text(&item.body)); - if let Some(nested) = item.body.query_first_naive(&EnumElem::ELEM.select()) { - if let Some(list) = nested.to_packed::() { - for child in &list.children { - collect_enum_item_texts(child, skip); - } + if let Some(nested) = item.body.query_first_naive(&EnumElem::ELEM.select()) + && let Some(list) = nested.to_packed::() + { + for child in &list.children { + collect_enum_item_texts(child, skip); } } } @@ -517,12 +569,14 @@ pub fn convert_from_introspector( introspector: &dyn Introspector, styles: StyleChain, doc_lang: Option, + ancestry: &Ancestry, ctx: &mut ConvertContext, ) -> typst_library::diag::SourceResult<()> { let skip_ranges = build_skip_ranges(engine, introspector); let skip_texts = build_skip_paragraph_texts(introspector); let list_enum_ranges = build_list_enum_ranges(engine, introspector); - let inline_nested_locations = build_inline_nested_list_enum_locations(engine, introspector); + let inline_nested_locations = + build_inline_nested_list_enum_locations(engine, introspector); let doc_selector = doc_selector(); let mut items: Vec<(Location, Content)> = Vec::new(); @@ -554,13 +608,10 @@ pub fn convert_from_introspector( }; for elem in introspector.query(&ImageElem::ELEM.select()) { let Some(loc) = elem.location() else { continue }; - if let Some(range) = source_range(engine, elem.span()) { - if image_skip_ranges - .iter() - .any(|outer| range_contains(*outer, range)) - { - continue; - } + if let Some(range) = source_range(engine, elem.span()) + && image_skip_ranges.iter().any(|outer| range_contains(*outer, range)) + { + continue; } items.push((loc, elem)); } @@ -576,7 +627,13 @@ pub fn convert_from_introspector( } for elem in introspector.query(&ListElem::ELEM.select()) { - if is_nested_list_or_enum(engine, introspector, &elem, &list_enum_ranges, &inline_nested_locations) { + if is_nested_list_or_enum( + engine, + introspector, + &elem, + &list_enum_ranges, + &inline_nested_locations, + ) { continue; } let Some(loc) = elem.location() else { continue }; @@ -584,7 +641,13 @@ pub fn convert_from_introspector( } for elem in introspector.query(&EnumElem::ELEM.select()) { - if is_nested_list_or_enum(engine, introspector, &elem, &list_enum_ranges, &inline_nested_locations) { + if is_nested_list_or_enum( + engine, + introspector, + &elem, + &list_enum_ranges, + &inline_nested_locations, + ) { continue; } let Some(loc) = elem.location() else { continue }; @@ -614,15 +677,40 @@ pub fn convert_from_introspector( items.push((loc, elem)); } + // Paragraphs skipped as part of an enclosing one, kept so that enclosing + // paragraph can absorb their edges. + let mut absorbed: Vec<(Location, Content)> = Vec::new(); + for elem in introspector.query(&ParElem::ELEM.select()) { let Some(par) = elem.to_packed::() else { continue }; let Some(loc) = elem.location() else { continue }; + // The body of an inline `box`, realized into a paragraph of its own. + // The paragraph containing the box renders that text itself, so this + // one is a duplicate — but it is the only copy that carries the + // box's footnote and citation tags, which are absorbed below. + if let Some(parent) = ancestry.covering_par(loc) { + absorbed.push((parent, elem.clone())); + continue; + } + // Likewise for a paragraph inside a list, table, code block or + // equation: that container is emitted as its own node and renders + // this text itself. + if ancestry.is_in_emitted_container(loc) { + continue; + } if should_skip_paragraph(engine, par, &elem, &skip_ranges, &skip_texts) { continue; } items.push((loc, elem)); } + // Page furniture (running headers and footers, page decorations) is + // marked as a PDF artifact by Typst's accessibility model, which defines + // it as not being document content. It is repeated on every page, so + // emitting it would add one copy of the footer — and one page number — + // per page. + items.retain(|(loc, _)| !ancestry.is_artifact(*loc)); + reading_order::sort_by_reading_order(&mut items, introspector, &doc_selector); let mut stack: Vec = Vec::new(); @@ -637,10 +725,50 @@ pub fn convert_from_introspector( &mut stack, )?; } + absorb_covered_paragraphs(ctx, &absorbed); finalize_headings(ctx, &mut stack); Ok(()) } +/// Move the footnote and citation edges of a paragraph that was skipped as +/// part of an enclosing one onto that enclosing paragraph. +/// +/// The enclosing paragraph's own content holds the box body *unrealized*, so +/// a footnote or citation written inside the box is a bare element there — +/// which `collect_footnote_locs` and `collect_cite_markers` deliberately +/// ignore, since they read the introspection tags that only realized content +/// carries. Skipping the realized paragraph without this would take the edge +/// with it. +/// +/// The absorbed markers carry no text span: their offsets index the skipped +/// paragraph's text, not the text the surviving node exposes. A span-less +/// edge is what a list item or a table cell already produces. +fn absorb_covered_paragraphs(ctx: &mut ConvertContext, absorbed: &[(Location, Content)]) { + for (parent, nested) in absorbed { + let Some(id) = ctx.location_to_id.get(parent).copied() else { continue }; + let footnotes = collect_footnote_locs(nested); + let cites = collect_cite_markers(nested); + let refs = collect_ref_targets(nested); + let Some(record) = ctx.records.get_mut(&id) else { continue }; + + for loc in footnotes { + if record.footnote_locs.iter().all(|(known, _)| *known != loc) { + record.footnote_locs.push((loc, None)); + } + } + for marker in cites { + if record.cite_markers.iter().all(|known| known.loc != marker.loc) { + record.cite_markers.push(marker); + } + } + for label in refs { + if !record.ref_targets.contains(&label) { + record.ref_targets.push(label); + } + } + } +} + fn dispatch( engine: &mut Engine, introspector: &dyn Introspector, @@ -653,11 +781,13 @@ fn dispatch( if let Some(heading) = content.to_packed::() { emit_heading(engine, introspector, heading, styles, ctx, stack)?; } else if let Some(par) = content.to_packed::() { - let (node, record) = paragraph::convert(engine, introspector, par, styles, doc_lang)?; + let (node, record) = + paragraph::convert(engine, introspector, par, styles, doc_lang)?; ctx.register(node.base.id, record); push_node(CndNode::Paragraph(node), ctx, stack); } else if let Some(quote) = content.to_packed::() { - let (node, record) = quote::convert(engine, introspector, quote, styles, doc_lang)?; + let (node, record) = + quote::convert(engine, introspector, quote, styles, doc_lang)?; ctx.register(node.base.id, record); push_node(CndNode::Quote(node), ctx, stack); } else if let Some(list) = content.to_packed::() { @@ -681,7 +811,8 @@ fn dispatch( ctx.register(node.base.id, record); push_node(CndNode::Math(node), ctx, stack); } else if let Some(image) = content.to_packed::() { - let (node, record) = figure::bare_image(engine, introspector, content, image, styles)?; + let (node, record) = + figure::bare_image(engine, introspector, content, image, styles)?; ctx.register(node.base.id, record); push_node(CndNode::Image(node), ctx, stack); } else if let Some(figure) = content.to_packed::() { @@ -753,16 +884,16 @@ fn emit_heading( let (node, record) = heading::convert(engine, introspector, heading, styles, stack)?; ctx.register(node.base.id, record); - stack.push(HeadingFrame { - level, - path: node.heading_path.clone(), - node, - }); + stack.push(HeadingFrame { level, path: node.heading_path.clone(), node }); Ok(()) } -fn attach_heading(frame: HeadingFrame, ctx: &mut ConvertContext, stack: &mut [HeadingFrame]) { +fn attach_heading( + frame: HeadingFrame, + ctx: &mut ConvertContext, + stack: &mut [HeadingFrame], +) { let node = CndNode::Heading(frame.node); if let Some(parent) = stack.last_mut() { parent.node.children.push(node); @@ -802,7 +933,8 @@ pub fn metadata_at( engine: &mut Engine, introspector: &dyn Introspector, location: Location, -) -> typst_library::diag::SourceResult> { +) -> typst_library::diag::SourceResult> +{ crate::metadata::metadata_at(engine, introspector, location) } @@ -824,8 +956,10 @@ pub fn make_record( ref_targets.dedup_by_key(|label| label.resolve()); // Span lookups from the flat-text walk (empty for non-flat nodes). - let mut cite_span: rustc_hash::FxHashMap = Default::default(); - let mut footnote_span: rustc_hash::FxHashMap = Default::default(); + let mut cite_span: rustc_hash::FxHashMap = + rustc_hash::FxHashMap::default(); + let mut footnote_span: rustc_hash::FxHashMap = + rustc_hash::FxHashMap::default(); let mut ref_markers: Vec<(Label, (i64, i64))> = Vec::new(); for marker in markers { match &marker.kind { @@ -835,7 +969,9 @@ pub fn make_record( MarkerKind::Footnote(loc) => { footnote_span.entry(*loc).or_insert((marker.start, marker.end)); } - MarkerKind::Ref(label) => ref_markers.push((*label, (marker.start, marker.end))), + MarkerKind::Ref(label) => { + ref_markers.push((*label, (marker.start, marker.end))); + } } } @@ -872,25 +1008,17 @@ pub fn make_record( fn collect_cite_markers(content: &Content) -> Vec { let mut markers = Vec::new(); let _ = content.traverse(&mut |element| { - if let Some(tag) = element.to_packed::() { - if let Tag::Start(inner, _) = &tag.tag { - if let Some(cite) = inner.to_packed::() { - if let Some(loc) = inner.location() { - let form = extract::citation_form_str(cite.form.get(StyleChain::default())); - let supplement = cite - .supplement - .get_cloned(StyleChain::default()) - .map(|c| extract::extract_text(&c).into()); - markers.push(CiteMarker { - key: cite.key, - loc, - form, - supplement, - span: None, - }); - } - } - } + if let Some(tag) = element.to_packed::() + && let Tag::Start(inner, _) = &tag.tag + && let Some(cite) = inner.to_packed::() + && let Some(loc) = inner.location() + { + let form = extract::citation_form_str(cite.form.get(StyleChain::default())); + let supplement = cite + .supplement + .get_cloned(StyleChain::default()) + .map(|c| extract::extract_text(&c).into()); + markers.push(CiteMarker { key: cite.key, loc, form, supplement, span: None }); } ControlFlow::<()>::Continue(()) }); @@ -907,16 +1035,13 @@ fn collect_cite_markers(content: &Content) -> Vec { fn collect_footnote_locs(content: &Content) -> Vec { let mut locs = Vec::new(); let _ = content.traverse(&mut |element| { - if let Some(tag) = element.to_packed::() { - if let Tag::Start(inner, _) = &tag.tag { - if inner.to_packed::().is_some() { - if let Some(loc) = inner.location() { - if !locs.contains(&loc) { - locs.push(loc); - } - } - } - } + if let Some(tag) = element.to_packed::() + && let Tag::Start(inner, _) = &tag.tag + && inner.to_packed::().is_some() + && let Some(loc) = inner.location() + && !locs.contains(&loc) + { + locs.push(loc); } ControlFlow::<()>::Continue(()) }); @@ -998,7 +1123,9 @@ pub fn figure_counter_label( // counter at all, so a label for it would be a word nothing precedes. figure.numbering.get_ref(styles).as_ref()?; match figure.supplement.get_ref(styles) { - typst_library::foundations::Smart::Custom(Some(Supplement::Content(supplement))) => { + typst_library::foundations::Smart::Custom(Some(Supplement::Content( + supplement, + ))) => { let text = extract::extract_text(supplement); (!text.is_empty()).then_some(text) } diff --git a/crates/typst-cnd/src/emit/extract.rs b/crates/typst-cnd/src/emit/extract.rs index 90bf016bcb18..ab175ce07325 100644 --- a/crates/typst-cnd/src/emit/extract.rs +++ b/crates/typst-cnd/src/emit/extract.rs @@ -2,6 +2,7 @@ use ecow::EcoString; use typst_library::foundations::{Content, Label, PlainText, Value}; use typst_library::introspection::{Location, Tag, TagElem}; use typst_library::model::{CitationForm, CiteElem, FootnoteElem, LinkMarker, RefElem}; +use typst_library::text::LinebreakElem; /// Extract plain text from content without duplicating inline code. /// @@ -65,8 +66,8 @@ struct MarkerCtx { /// Zero-width `RefElem` tags awaiting the `LinkMarker` that renders the /// reference text. A ref tag is a point marker; its rendered supplement /// + number is wrapped in a following `LinkMarker` starting at the same - /// position (this fork's realization — cites/footnotes differ, their own - /// tag brackets the text). + /// position (this fork's realization — cites/footnotes differ, their own + /// tag brackets the text). pending_refs: Vec, /// `LinkMarker` frames currently bracketing a pending ref's text. link_for_ref: Vec, @@ -112,6 +113,18 @@ fn walk(content: &Content, out: &mut EcoString, ctx: &mut MarkerCtx) { return; } + // A hard line break (`\\`) renders as a new line but carries no text, so + // `PlainText` yields nothing for it and the words on either side would be + // run together ("Plateforme de calcul \\ de coût" -> "calculde coût"). + // A node's `text` is one flat string, so the break becomes a space. + if content.to_packed::().is_some() { + if !out.is_empty() && !out.ends_with(char::is_whitespace) { + out.push(' '); + ctx.pos += 1; + } + return; + } + if let Some(textable) = content.with::() { let before = out.len(); textable.plain_text(out); @@ -130,14 +143,19 @@ fn open_tag(inner: &Content, ctx: &mut MarkerCtx) { if let Some(reference) = inner.to_packed::() { // Point marker — defer until its LinkMarker renders the text. - ctx.pending_refs.push(PendingRef { label: reference.target, start: pos }); + ctx.pending_refs + .push(PendingRef { label: reference.target, start: pos }); return; } if inner.to_packed::().is_some() { // Pair with the pending ref that starts here, if any. if let Some(index) = ctx.pending_refs.iter().position(|r| r.start == pos) { let pending = ctx.pending_refs.remove(index); - ctx.link_for_ref.push(LinkFrame { marker_loc, label: pending.label, start: pending.start }); + ctx.link_for_ref.push(LinkFrame { + marker_loc, + label: pending.label, + start: pending.start, + }); } return; } @@ -150,11 +168,16 @@ fn close_tag(ctx: &mut MarkerCtx, loc: Location) { // A cite/footnote tag closes its own bracketed span. if let Some(index) = ctx.open.iter().position(|frame| frame.marker_loc == loc) { let frame = ctx.open.remove(index); - ctx.done.push(ExtractedMarker { kind: frame.kind, start: frame.start, end: ctx.pos }); + ctx.done.push(ExtractedMarker { + kind: frame.kind, + start: frame.start, + end: ctx.pos, + }); return; } // A LinkMarker closing finishes the pending ref it wraps. - if let Some(index) = ctx.link_for_ref.iter().position(|frame| frame.marker_loc == loc) { + if let Some(index) = ctx.link_for_ref.iter().position(|frame| frame.marker_loc == loc) + { let frame = ctx.link_for_ref.remove(index); ctx.done.push(ExtractedMarker { kind: MarkerKind::Ref(frame.label), @@ -187,10 +210,18 @@ fn open_frame(inner: &Content, start: i64) -> Option { // rendered group ("[1], [2]") and later members get a zero-width // span at the group's end. Typst does not expose per-member // sub-spans; emit what the tags give us. - return Some(OpenFrame { marker_loc, kind: MarkerKind::Cite(marker_loc), start }); + return Some(OpenFrame { + marker_loc, + kind: MarkerKind::Cite(marker_loc), + start, + }); } if inner.to_packed::().is_some() { - return Some(OpenFrame { marker_loc, kind: MarkerKind::Footnote(marker_loc), start }); + return Some(OpenFrame { + marker_loc, + kind: MarkerKind::Footnote(marker_loc), + start, + }); } None } diff --git a/crates/typst-cnd/src/emit/figure.rs b/crates/typst-cnd/src/emit/figure.rs index 67fb2f6fb532..72e7ea5f6975 100644 --- a/crates/typst-cnd/src/emit/figure.rs +++ b/crates/typst-cnd/src/emit/figure.rs @@ -1,5 +1,7 @@ use typst_library::engine::Engine; -use typst_library::foundations::{Content, NativeElement, Packed, Smart, StyleChain, Synthesize}; +use typst_library::foundations::{ + Content, NativeElement, Packed, Smart, StyleChain, Synthesize, +}; use typst_library::introspection::Introspector; use typst_library::loading::DataSource; use typst_library::model::{FigureElem, FigureKind}; @@ -40,8 +42,7 @@ pub fn from_figure( let mut node = FigureNode::new(id, location); node.caption = caption; node.number = fig_number; - node.counter_label = - convert::figure_counter_label(&figure, styles).map(Into::into); + node.counter_label = convert::figure_counter_label(&figure, styles).map(Into::into); node.kind = figure_kind(&figure, styles); let mut records = vec![(id, record)]; @@ -55,13 +56,13 @@ pub fn from_figure( records.push((child_id, minimal_record(content))); node.children.push(CndNode::Image(child)); } - } else if let Some(raw) = content.query_first_naive(&RawElem::ELEM.select()) { - if let Some(raw) = raw.to_packed::() { - let (child, child_record) = code::convert(engine, introspector, raw, styles)?; - let child_id = child.base.id; - records.push((child_id, child_record)); - node.children.push(CndNode::Code(child)); - } + } else if let Some(raw) = content.query_first_naive(&RawElem::ELEM.select()) + && let Some(raw) = raw.to_packed::() + { + let (child, child_record) = code::convert(engine, introspector, raw, styles)?; + let child_id = child.base.id; + records.push((child_id, child_record)); + node.children.push(CndNode::Code(child)); } node.raw = table::raw_typst_for_label(engine, content.label()).map(RawSource::typst); @@ -109,7 +110,10 @@ fn minimal_record(content: &Content) -> NodeRecord { /// image) or an author-supplied custom string — instead of recomputing a /// cruder image-only guess. `kind` is always `Smart::Custom` after /// `synthesize()`; the `None` arm only guards a future Typst change. -pub(crate) fn figure_kind(figure: &Packed, styles: StyleChain) -> Option { +pub(crate) fn figure_kind( + figure: &Packed, + styles: StyleChain, +) -> Option { match figure.kind.get_cloned(styles) { Smart::Custom(FigureKind::Elem(func)) => Some(func.name().to_string()), Smart::Custom(FigureKind::Name(name)) => Some(name.to_string()), @@ -123,6 +127,6 @@ fn image_path(image: &Packed) -> Option { .resolve_if_some(image.span().id()) .ok() .map(|resolved| resolved.vpath().get_without_slash().to_string()), - _ => None, + DataSource::Bytes(_) => None, } } diff --git a/crates/typst-cnd/src/emit/list.rs b/crates/typst-cnd/src/emit/list.rs index 60d7c736ef51..90bca5dcfba6 100644 --- a/crates/typst-cnd/src/emit/list.rs +++ b/crates/typst-cnd/src/emit/list.rs @@ -6,7 +6,9 @@ use typst_library::model::{EnumElem, EnumItem, ListElem, ListItem, TermItem, Ter use crate::emit::convert::{self, NodeRecord}; use crate::emit::extract::extract_text; use crate::location::placeholder_location; -use crate::model::{ListItem as CndListItem, ListNode, TermItem as CndTermItem, TermsNode}; +use crate::model::{ + ListItem as CndListItem, ListNode, TermItem as CndTermItem, TermsNode, +}; pub fn from_list( engine: &mut Engine, @@ -22,11 +24,7 @@ pub fn from_list( let mut node = ListNode::new(id, location); node.ordered = false; node.tight = list.tight.get(styles); - node.items = list - .children - .iter() - .map(|item| list_item(item, false, styles)) - .collect(); + node.items = list.children.iter().map(list_item).collect(); Ok((node, record)) } @@ -83,13 +81,11 @@ fn term_item(item: &Packed) -> CndTermItem { } } -fn list_item(item: &Packed, ordered: bool, styles: StyleChain) -> CndListItem { - let nested = if let Some(list) = item.body.query_first_naive(&ListElem::ELEM.select()) { +fn list_item(item: &Packed) -> CndListItem { + let nested = if let Some(list) = item.body.query_first_naive(&ListElem::ELEM.select()) + { if let Some(list) = list.to_packed::() { - list.children - .iter() - .map(|child| list_item(child, ordered, styles)) - .collect() + list.children.iter().map(list_item).collect() } else { Vec::new() } @@ -107,10 +103,11 @@ fn list_item(item: &Packed, ordered: bool, styles: StyleChain) -> CndL fn enum_item(item: &Packed, index: usize, styles: StyleChain) -> CndListItem { let number = match item.number.get(styles) { Smart::Custom(n) => Some(n as i32), - _ => Some((index + 1) as i32), + Smart::Auto => Some((index + 1) as i32), }; - let nested = if let Some(list) = item.body.query_first_naive(&EnumElem::ELEM.select()) { + let nested = if let Some(list) = item.body.query_first_naive(&EnumElem::ELEM.select()) + { if let Some(list) = list.to_packed::() { list.children .iter() diff --git a/crates/typst-cnd/src/emit/math.rs b/crates/typst-cnd/src/emit/math.rs index 5e6d3fc7351b..dee74d8e1ea5 100644 --- a/crates/typst-cnd/src/emit/math.rs +++ b/crates/typst-cnd/src/emit/math.rs @@ -22,7 +22,7 @@ pub fn convert( let mut equation = equation.clone(); equation.synthesize(engine, styles)?; - let text: EcoString = extract_text(&equation.body); + let text = extract_text(&equation.body); let block = equation.block.get(styles); let numbering: Option = equation.numbering().and_then(|numbering| { let location = equation.location()?; diff --git a/crates/typst-cnd/src/emit/mod.rs b/crates/typst-cnd/src/emit/mod.rs index 5c3adb95b247..d90bf27a65a9 100644 --- a/crates/typst-cnd/src/emit/mod.rs +++ b/crates/typst-cnd/src/emit/mod.rs @@ -1,3 +1,4 @@ +pub mod ancestry; pub mod code; pub mod convert; pub mod extract; diff --git a/crates/typst-cnd/src/emit/pools.rs b/crates/typst-cnd/src/emit/pools.rs index 2e54919cda72..aafda2733e60 100644 --- a/crates/typst-cnd/src/emit/pools.rs +++ b/crates/typst-cnd/src/emit/pools.rs @@ -57,11 +57,8 @@ pub fn resolve_footnotes( }; let id = *ctx.footnote_loc_to_id.entry(own_loc).or_insert_with(Uuid::new_v4); if !ctx.footnotes.iter().any(|f| f.id == id) { - let text = footnote - .body_content() - .map(extract_text) - .unwrap_or_default() - .into(); + let text = + footnote.body_content().map(extract_text).unwrap_or_default().into(); let numbering = footnote.numbering.get_ref(styles); let label = counter .display_at(engine, own_loc, styles, numbering, footnote.span()) @@ -129,9 +126,8 @@ pub fn build_bibliography_pool( // Recover the source key and hayagriva entry (rendered order may // differ from source order — match by the key we retained). let key_str = entry.key.as_str(); - let Some((label, source)) = database - .iter() - .find(|(label, _)| label.resolve().as_str() == key_str) + let Some((label, source)) = + database.iter().find(|(label, _)| label.resolve().as_str() == key_str) else { continue; }; @@ -178,16 +174,15 @@ fn rendered_string(entry: &RenderedEntry) -> String { match &entry.prefix { Some(prefix) => { let prefix = extract_text(prefix); - if prefix.is_empty() { - body.to_string() - } else { - format!("{prefix} {body}") - } + if prefix.is_empty() { body.to_string() } else { format!("{prefix} {body}") } } None => body.to_string(), } } +/// `(source node, footnote pool entry, the marker's text span)`. +type FootnoteEdge = (Uuid, Uuid, Option<(i64, i64)>); + /// Resolve every node's citation markers to `CiteRef` edges pointing at the /// bibliography pool. A key with no pool entry is dropped silently. The /// marker's text span is carried through, except for a suppressed @@ -195,20 +190,17 @@ fn rendered_string(entry: &RenderedEntry) -> String { /// (ADR 0013 / proposal 0004). pub fn resolve_cite_edges(ctx: &mut ConvertContext) { let mut edges: Vec<(Uuid, CiteRef)> = Vec::new(); - for (node_id, record) in &ctx.records { + for (node_id, record) in ctx.records_sorted() { for marker in &record.cite_markers { // The edge names its target by label now, but the lookup still // gates it: a `@key` with no bibliography entry must not // produce an unresolvable `cites` edge. if ctx.bib_key_to_id.contains_key(&marker.key) { let suppressed = marker.form.as_deref() == Some("none"); - let text_span = if suppressed { - None - } else { - marker.span.map(|(s, e)| vec![s, e]) - }; + let text_span = + if suppressed { None } else { marker.span.map(|(s, e)| vec![s, e]) }; edges.push(( - *node_id, + node_id, CiteRef { label: marker.key.resolve().to_string(), text_span, @@ -237,17 +229,14 @@ pub fn resolve_cite_edges(ctx: &mut ConvertContext) { /// footnote) is dropped. Dedup is by the full edge (id + span), so two /// markers of the same note in one node become two positioned entries. fn resolve_footnote_edges(ctx: &mut ConvertContext) { - let labels: rustc_hash::FxHashMap = ctx - .footnotes - .iter() - .map(|f| (f.id, f.label.clone())) - .collect(); + let labels: rustc_hash::FxHashMap = + ctx.footnotes.iter().map(|f| (f.id, f.label.clone())).collect(); - let mut edges: Vec<(Uuid, Uuid, Option<(i64, i64)>)> = Vec::new(); - for (node_id, record) in &ctx.records { + let mut edges: Vec = Vec::new(); + for (node_id, record) in ctx.records_sorted() { for (marker_loc, span) in &record.footnote_locs { if let Some(pool_id) = ctx.footnote_loc_to_id.get(marker_loc).copied() { - edges.push((*node_id, pool_id, *span)); + edges.push((node_id, pool_id, *span)); } } } @@ -257,10 +246,7 @@ fn resolve_footnote_edges(ctx: &mut ConvertContext) { // ordinal), so this lookup cannot fail; skipping rather than // unwrapping keeps an unresolvable edge from ever being emitted. let Some(label) = labels.get(&pool_id).cloned() else { continue }; - let reference = FootnoteRef { - label, - text_span: span.map(|(s, e)| vec![s, e]), - }; + let reference = FootnoteRef { label, text_span: span.map(|(s, e)| vec![s, e]) }; if let Some(node) = find_node_mut(&mut ctx.roots, node_id) { let footnotes = node.footnotes_mut(); if !footnotes.contains(&reference) { diff --git a/crates/typst-cnd/src/emit/reading_order.rs b/crates/typst-cnd/src/emit/reading_order.rs index cd46a5c7b402..9b208fd25452 100644 --- a/crates/typst-cnd/src/emit/reading_order.rs +++ b/crates/typst-cnd/src/emit/reading_order.rs @@ -5,8 +5,6 @@ //! column-major for LTR text — top-to-bottom within a column, then the next //! column to the right. -use std::cmp::Ordering; - use typst_library::foundations::Selector; use typst_library::introspection::{Introspector, Location}; use typst_library::layout::Abs; @@ -28,8 +26,11 @@ pub fn sort_by_reading_order( doc_selector: &Selector, ) { items.sort_by(|(loc_a, _), (loc_b, _)| { - reading_key(introspector, doc_selector, *loc_a) - .cmp(&reading_key(introspector, doc_selector, *loc_b)) + reading_key(introspector, doc_selector, *loc_a).cmp(&reading_key( + introspector, + doc_selector, + *loc_b, + )) }); } @@ -38,10 +39,7 @@ fn reading_key( doc_selector: &Selector, location: Location, ) -> ReadingKey { - let page = introspector - .page(location) - .map(|page| page.get() as u32) - .unwrap_or(1); + let page = introspector.page(location).map(|page| page.get() as u32).unwrap_or(1); let (x, y) = introspector .position(location) .map(|pos| pos.as_paged_or_default().point) @@ -57,17 +55,6 @@ fn abs_key(value: Abs) -> i64 { (value.to_pt() * 1_000.0).round() as i64 } -/// Compare two locations in reading order (useful for tests). -pub fn compare_locations( - introspector: &dyn Introspector, - doc_selector: &Selector, - left: Location, - right: Location, -) -> Ordering { - reading_key(introspector, doc_selector, left) - .cmp(&reading_key(introspector, doc_selector, right)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/typst-cnd/src/emit/refs.rs b/crates/typst-cnd/src/emit/refs.rs index 9ff2f8e6338c..e753e4c8cf76 100644 --- a/crates/typst-cnd/src/emit/refs.rs +++ b/crates/typst-cnd/src/emit/refs.rs @@ -12,6 +12,9 @@ use uuid::Uuid; use crate::emit::convert::ConvertContext; use crate::model::{CndNode, NodeRef}; +/// `(source node, target node, the target's label, the marker's text span)`. +type RefEdge = (Uuid, Uuid, Option, Option<(i64, i64)>); + fn doc_selector() -> Selector { Selector::Or(eco_vec![ HeadingElem::ELEM.select(), @@ -39,7 +42,7 @@ pub fn resolve_refs( // populated only on the primary path, from the source node's flat-text // `ref_markers` (ADR 0013); it runs first, so it wins the dedup-by- // target-id in `set_ref` and the fallback path's `None` never clobbers it. - let mut edges: Vec<(Uuid, Uuid, Option, Option<(i64, i64)>)> = Vec::new(); + let mut edges: Vec = Vec::new(); let _selector = doc_selector(); // Primary path: a flat node's own `ref_markers` — the marker is in that @@ -48,8 +51,9 @@ pub fn resolve_refs( // any node that carries markers, which is where refs actually resolve // (the realized content keeps only the rendered link, not a bare // RefElem, so `ref_targets` is empty for flat nodes). - let mut ref_marker_sources: rustc_hash::FxHashSet = Default::default(); - for (source_id, record) in &ctx.records { + let mut ref_marker_sources: rustc_hash::FxHashSet = + rustc_hash::FxHashSet::default(); + for (source_id, record) in ctx.records_sorted() { // `ref_markers` first: these carry spans, and `set_ref` dedups // first-wins per target, so the spanned edge must be pushed before // the span-less `ref_targets` edge for the same target (today @@ -59,10 +63,10 @@ pub fn resolve_refs( if ctx.bib_key_to_id.contains_key(label) { continue; } - if let Some(target_id) = resolve_label(label, ctx, introspector) { - ref_marker_sources.insert(*source_id); + if let Some(target_id) = resolve_label(*label, ctx, introspector) { + ref_marker_sources.insert(source_id); edges.push(( - *source_id, + source_id, target_id, Some(label.resolve().as_str().into()), Some(*span), @@ -75,8 +79,13 @@ pub fn resolve_refs( if ctx.bib_key_to_id.contains_key(label) { continue; } - if let Some(target_id) = resolve_label(label, ctx, introspector) { - edges.push((*source_id, target_id, Some(label.resolve().as_str().into()), None)); + if let Some(target_id) = resolve_label(*label, ctx, introspector) { + edges.push(( + source_id, + target_id, + Some(label.resolve().as_str().into()), + None, + )); } } } @@ -101,14 +110,24 @@ pub fn resolve_refs( if ref_marker_sources.contains(&source_id) { continue; } - if let Some(target_id) = resolve_label(&label, ctx, introspector) { - edges.push((source_id, target_id, Some(label.resolve().as_str().into()), None)); - if matches!(source_kind, RefSourceKind::Heading) { - if let Some(paragraph_id) = last_paragraph_under_heading(&ctx.roots, source_id) { - if !ref_marker_sources.contains(¶graph_id) { - edges.push((paragraph_id, target_id, Some(label.resolve().as_str().into()), None)); - } - } + if let Some(target_id) = resolve_label(label, ctx, introspector) { + edges.push(( + source_id, + target_id, + Some(label.resolve().as_str().into()), + None, + )); + if matches!(source_kind, RefSourceKind::Heading) + && let Some(paragraph_id) = + last_paragraph_under_heading(&ctx.roots, source_id) + && !ref_marker_sources.contains(¶graph_id) + { + edges.push(( + paragraph_id, + target_id, + Some(label.resolve().as_str().into()), + None, + )); } } } @@ -136,8 +155,8 @@ enum RefSourceKind { fn ref_edges_from_content(content: &Content) -> Vec<(RefSourceKind, usize, Label)> { let mut out = Vec::new(); let mut current: Option<(RefSourceKind, usize)> = None; - let mut par_index = 0usize; - let mut heading_index = 0usize; + let mut par_index = 0_usize; + let mut heading_index = 0_usize; let _ = content.traverse(&mut |element| { if element.to_packed::().is_some() { current = Some((RefSourceKind::Paragraph, par_index)); @@ -145,10 +164,10 @@ fn ref_edges_from_content(content: &Content) -> Vec<(RefSourceKind, usize, Label } else if element.to_packed::().is_some() { current = Some((RefSourceKind::Heading, heading_index)); heading_index += 1; - } else if let Some(reference) = element.to_packed::() { - if let Some((kind, index)) = current { - out.push((kind, index, reference.target)); - } + } else if let Some(reference) = element.to_packed::() + && let Some((kind, index)) = current + { + out.push((kind, index, reference.target)); } ControlFlow::<()>::Continue(()) }); @@ -193,49 +212,53 @@ fn walk_heading_ids(nodes: &[CndNode], out: &mut Vec) { } } +/// The id of the node carrying `label`. +/// +/// Two nodes sharing a label is ill-formed but possible, and then the +/// smallest id wins rather than whichever the hash map happened to yield +/// first — so the two calls this makes per labelled element agree with each +/// other. `min_by_key` rather than sorting: this runs once per labelled +/// element, and sorting the whole map each time would be quadratic. +fn find_by_label(ctx: &ConvertContext, label: Label) -> Option { + ctx.records + .iter() + .filter(|(_, record)| record.label == Some(label)) + .map(|(id, _)| *id) + .min() +} + fn resolve_label( - label: &Label, + label: Label, ctx: &ConvertContext, introspector: &dyn Introspector, ) -> Option { - if let Some(id) = ctx.label_to_id.get(label).copied() { + if let Some(id) = ctx.label_to_id.get(&label).copied() { return Some(id); } - let content = introspector.query_label(*label).ok()?; - if let Some(loc) = content.location() { - if let Some(id) = ctx.location_to_id.get(&loc).copied() { - return Some(id); - } + let content = introspector.query_label(label).ok()?; + if let Some(loc) = content.location() + && let Some(id) = ctx.location_to_id.get(&loc).copied() + { + return Some(id); } for elem in introspector.query(&FigureElem::ELEM.select()) { - if elem.label() != Some(*label) { + if elem.label() != Some(label) { continue; } - if let Some(loc) = elem.location() { - if let Some(id) = ctx.location_to_id.get(&loc).copied() { - return Some(id); - } + if let Some(loc) = elem.location() + && let Some(id) = ctx.location_to_id.get(&loc).copied() + { + return Some(id); } } if content.to_packed::().is_some() { - for (id, record) in &ctx.records { - if record.label.as_ref() == Some(label) { - return Some(*id); - } - } - return find_labeled_table(ctx); + return find_by_label(ctx, label).or_else(|| find_labeled_table(ctx)); } - for (id, record) in &ctx.records { - if record.label.as_ref() == Some(label) { - return Some(*id); - } - } - - None + find_by_label(ctx, label) } fn find_labeled_table(ctx: &ConvertContext) -> Option { @@ -266,17 +289,16 @@ fn find_labeled_table(ctx: &ConvertContext) -> Option { fn last_paragraph_under_heading(nodes: &[CndNode], heading_id: Uuid) -> Option { fn walk(nodes: &[CndNode], target: Uuid, last: &mut Option) -> bool { for node in nodes { - match node { - CndNode::Heading(n) if n.base.id == target => { - find_last_paragraph(&n.children, last); - return true; - } - CndNode::Heading(n) => { - if walk(&n.children, target, last) { - return true; - } - } - _ => {} + // Not a `match` with a guard: the recursive descent writes through + // `last`, and a side-effecting guard would run — or not — by arm + // order alone. + let CndNode::Heading(n) = node else { continue }; + if n.base.id == target { + find_last_paragraph(&n.children, last); + return true; + } + if walk(&n.children, target, last) { + return true; } } false @@ -310,10 +332,10 @@ fn node_label(ctx: &ConvertContext, id: Uuid) -> Option { if node.id() == id { return node.base().label.clone(); } - if let CndNode::Heading(h) = node { - if let Some(label) = walk(&h.children, id) { - return Some(label); - } + if let CndNode::Heading(h) = node + && let Some(label) = walk(&h.children, id) + { + return Some(label); } } None @@ -334,10 +356,7 @@ fn set_ref( // globally unique (ADR 0017), so this is the same dedup the target // id used to give. if !refs.iter().any(|reference| reference.label == label) { - refs.push(NodeRef { - label, - text_span: span.map(|(s, e)| vec![s, e]), - }); + refs.push(NodeRef { label, text_span: span.map(|(s, e)| vec![s, e]) }); } } } @@ -347,10 +366,10 @@ pub fn find_node_mut(nodes: &mut [CndNode], id: Uuid) -> Option<&mut CndNode> { if node.id() == id { return Some(node); } - if let Some(children) = node.children_mut() { - if let Some(found) = find_node_mut(children, id) { - return Some(found); - } + if let Some(children) = node.children_mut() + && let Some(found) = find_node_mut(children, id) + { + return Some(found); } } None @@ -358,16 +377,17 @@ pub fn find_node_mut(nodes: &mut [CndNode], id: Uuid) -> Option<&mut CndNode> { /// Build label lookup from records and labelled introspector elements. pub fn rebuild_label_index(ctx: &mut ConvertContext, introspector: &dyn Introspector) { + let labelled: Vec<(Label, Uuid)> = ctx + .records_sorted() + .into_iter() + .filter_map(|(id, record)| record.label.map(|label| (label, id))) + .collect(); ctx.label_to_id.clear(); - for (id, record) in &ctx.records { - if let Some(label) = record.label { - ctx.label_to_id.insert(label, *id); - } - } + ctx.label_to_id.extend(labelled); for elem in introspector.query_labelled() { let Some(label) = elem.label() else { continue }; - let Some(id) = resolve_label(&label, ctx, introspector) else { continue }; + let Some(id) = resolve_label(label, ctx, introspector) else { continue }; ctx.label_to_id.insert(label, id); if let Some(record) = ctx.records.get_mut(&id) { record.label = Some(label); diff --git a/crates/typst-cnd/src/emit/table.rs b/crates/typst-cnd/src/emit/table.rs index 66f7d2973320..dbf6a3789c0d 100644 --- a/crates/typst-cnd/src/emit/table.rs +++ b/crates/typst-cnd/src/emit/table.rs @@ -1,12 +1,16 @@ use ecow::EcoString; -use typst_library::engine::Engine; use typst_library::World; use typst_library::WorldExt; -use typst_library::foundations::{Content, Label, NativeElement, Packed, StyleChain, Synthesize}; +use typst_library::engine::Engine; +use typst_library::foundations::{ + Content, Label, NativeElement, Packed, StyleChain, Synthesize, +}; use typst_library::introspection::Introspector; use typst_library::layout::GridElem; use typst_library::layout::grid::resolve::{CellGrid, Entry}; -use typst_library::model::{FigureCaption, FigureElem, TableChild, TableElem, TableHeader, TableItem}; +use typst_library::model::{ + FigureCaption, FigureElem, TableChild, TableElem, TableHeader, TableItem, +}; use typst_syntax::Span; use uuid::Uuid; @@ -18,7 +22,8 @@ use crate::model::{CndNode, FigureNode, RawSource, TableCell, TableKind, TableNo /// Find a table element nested inside realized figure (or other) content. pub fn table_in_content(content: &Content) -> Option> { - table_content_in(content).and_then(|table| table.to_packed().cloned()) + let table = table_content_in(content)?; + table.to_packed().cloned() } /// Like [`table_in_content`], but keeps the introspector [`Content`] (with location). @@ -28,9 +33,8 @@ pub fn table_content_in(content: &Content) -> Option { /// Find a grid element nested inside figure content. pub fn grid_in_content(content: &Content) -> Option> { - content - .query_first_naive(&GridElem::ELEM.select()) - .and_then(|grid| grid.to_packed().cloned()) + let grid = content.query_first_naive(&GridElem::ELEM.select())?; + grid.to_packed().cloned() } /// Public alias used by figure emission. @@ -43,7 +47,7 @@ pub fn is_table_in_figure(introspector: &dyn Introspector, table: &Content) -> b let Some(table_packed) = table.to_packed::() else { return false; }; - let fingerprint = table_fingerprint(&table_packed); + let fingerprint = table_fingerprint(table_packed); introspector .query(&FigureElem::ELEM.select()) .into_iter() @@ -85,7 +89,6 @@ pub fn from_figure( .or_else(|| caption_from_source(engine, figure.span())) .or_else(|| caption_from_source(engine, Span::detached())); let fig_number = convert::figure_number(engine, &figure, styles) - .map(Into::into) .or_else(|| figure_number_fallback(introspector, content)); let wrapper_record = convert::make_record(engine, introspector, content, &[])?; @@ -215,7 +218,9 @@ pub fn convert( /// typed field — the one `cnd.core.node_text`'s "auto"/"inline" rendering /// actually reads. Only a string value is accepted; anything else (wrong /// type, or simply absent) leaves the hint unset rather than guessing. -fn content_kind_from_metadata(metadata: &std::collections::HashMap) -> Option { +fn content_kind_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { match metadata.get("content_kind") { Some(serde_json::Value::String(s)) => Some(s.clone()), _ => None, @@ -223,7 +228,7 @@ fn content_kind_from_metadata(metadata: &std::collections::HashMap) -> i32 { - let mut rows = 0usize; + let mut rows = 0_usize; for child in &table.children { if let TableChild::Header(header) = child { rows += count_header_items(header); @@ -236,7 +241,7 @@ fn count_header_rows(table: &Packed) -> i32 { } fn count_header_items(header: &Packed) -> usize { - let mut max_row = 0usize; + let mut max_row = 0_usize; for item in &header.children { if let TableItem::Cell(cell) = item { max_row = max_row.max(1); @@ -310,16 +315,16 @@ fn raw_typst_from_label(engine: &Engine, label: Option