From 7230df7abf9d3648b7efe9dae71e9b4fbe694426 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Thu, 17 Sep 2026 21:54:27 +0200 Subject: [PATCH 1/9] fix(cnd): recover text laid out in fully-inline fragment bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text inside a `block[…]`, a grid cell, a `place` or any user function building on them was silently missing from the CND. A deck whose slides are made of such containers exported only a handful of nodes. Typst does not build a `ParElem` for a fragment body that turns out to be entirely inline: `is_fully_inline_or_neutral` pops the paragraph grouping and reports `FragmentKind::Inline` instead. The text is laid out correctly but never gets a `Location`, so it is invisible to `Introspector::query` — the only source the emit pipeline reads — and is dropped. This also explains why the loss looked arbitrary: a card title survived (its block holds a `v()` and nested blocks, so the grouping finishes) while the card's items did not. `Feature::CndSemantics` forces the paragraph to be built. It is fork-local, excluded from `Feature::all()` and unmapped in the CLI, because it changes layout (an inline fragment becomes `FlowMode::Block`) and must never be on for PDF/HTML output; only typst-cnd's own world enables it. Forcing those paragraphs into existence duplicates text that is already emitted elsewhere, so `emit::ancestry` reconstructs real containment from the laid-out frames, where introspection tags appear as balanced `Tag::Start`/`Tag::End` pairs — the introspector itself returns a flat list. A paragraph is dropped when the nearest enclosing paragraph already carries its text (the body of an inline `box`), when it sits in a container emitted as its own node (list, table, code block, equation, footnote entry), or when every glyph it draws lands inside an `ArtifactElem` (running headers and footers, repeated on every page). Only the nearest enclosing paragraph is consulted: an outer paragraph may repeat the same words by coincidence, and dropping content on that basis would lose it outright. Across the example corpus 18 of 19 fixtures keep an identical node count. The newsletter gains seven genuinely recovered strings and loses two that were pre-existing leaks — a quote's attribution and a figure's caption emitted a second time as paragraphs, both still carried by their own nodes. Its assertion counted those leaks, and now pins the four real prose paragraphs instead. Co-Authored-By: Claude Opus 5 --- .../typst-cnd/examples/layout_containers.typ | 45 ++++ crates/typst-cnd/src/document.rs | 9 + crates/typst-cnd/src/emit/ancestry.rs | 200 ++++++++++++++++++ crates/typst-cnd/src/emit/convert.rs | 20 ++ crates/typst-cnd/src/emit/mod.rs | 1 + crates/typst-cnd/src/world.rs | 10 +- crates/typst-cnd/tests/document_suite.rs | 90 +++++++- crates/typst-library/src/lib.rs | 18 ++ crates/typst-realize/src/lib.rs | 9 + 9 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 crates/typst-cnd/examples/layout_containers.typ create mode 100644 crates/typst-cnd/src/emit/ancestry.rs diff --git a/crates/typst-cnd/examples/layout_containers.typ b/crates/typst-cnd/examples/layout_containers.typ new file mode 100644 index 000000000000..fc2d5d35a0e9 --- /dev/null +++ b/crates/typst-cnd/examples/layout_containers.typ @@ -0,0 +1,45 @@ +// 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.]) diff --git a/crates/typst-cnd/src/document.rs b/crates/typst-cnd/src/document.rs index a216511333db..8316b822b9c9 100644 --- a/crates/typst-cnd/src/document.rs +++ b/crates/typst-cnd/src/document.rs @@ -9,6 +9,7 @@ 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; @@ -68,12 +69,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); + 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); @@ -116,6 +123,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 +131,7 @@ fn realize_and_convert( introspector, styles, doc_lang_from_info(info), + ancestry, &mut ctx, )?; Ok(ctx) diff --git a/crates/typst-cnd/src/emit/ancestry.rs b/crates/typst-cnd/src/emit/ancestry.rs new file mode 100644 index 000000000000..bd7faaf9212d --- /dev/null +++ b/crates/typst-cnd/src/emit/ancestry.rs @@ -0,0 +1,200 @@ +//! 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 ecow::EcoString; +use rustc_hash::FxHashSet; +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::{ + EnumElem, FigureElem, FootnoteEntry, HeadingElem, ListElem, ParElem, QuoteElem, TableElem, + TermsElem, +}; +use typst_library::pdf::ArtifactElem; +use typst_library::text::RawElem; + +use crate::emit::extract; + +/// Which of a laid-out element's ancestors matter to the emit pipeline. +#[derive(Debug, Default)] +pub struct Ancestry { + /// Paragraphs whose text an enclosing paragraph already carries. + covered_by_par: FxHashSet, + /// 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) -> Self { + let mut ancestry = Self::default(); + let mut stack: Vec = Vec::new(); + for page in paged.pages() { + walk(&page.frame, &mut stack, &mut ancestry); + } + ancestry + } + + /// Whether an enclosing paragraph's own text already contains this + /// paragraph's text, which makes emitting it a duplicate. + /// + /// This is the body of an inline `box`: the box is part of the enclosing + /// paragraph's content, so that paragraph renders the text itself. It is + /// deliberately *not* every paragraph with a paragraph ancestor — content + /// reached through a `context` block is realized separately and is **not** + /// part of the enclosing paragraph's text, so skipping it would drop it + /// from the document entirely. + pub fn is_covered_by_par(&self, location: Location) -> bool { + self.covered_by_par.contains(&location) + } + + /// 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 `elem` is emitted as a node that renders its own content, making +/// any paragraph laid out inside it a duplicate. 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. +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 +} + +/// A tag that is currently open while walking the frames. +struct Open { + location: Location, + /// The paragraph's own text, kept so a nested paragraph can be tested for + /// being already covered by it. `None` for non-paragraphs. + par_text: 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, 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, stack, ancestry), + FrameItem::Tag(Tag::Start(inner, _)) => { + let Some(location) = inner.location() else { continue }; + let is_par = inner.elem() == ParElem::ELEM; + + if is_par && covered_by_enclosing_par(stack, inner) { + ancestry.covered_by_par.insert(location); + } + 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_text: is_par.then(|| extract::extract_text(inner)), + 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_text.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_text.is_some()) { + if in_artifact { + open.glyphs_in_artifact += 1; + } else { + open.glyphs_outside_artifact += 1; + } + } + } + _ => {} + } + } +} + +/// Whether the *nearest* enclosing paragraph's text already contains +/// `inner`'s text. +/// +/// Only the nearest one is consulted: an outer paragraph further up may +/// contain the same words by coincidence, and dropping a paragraph on that +/// basis would lose content outright. +fn covered_by_enclosing_par(stack: &[Open], inner: &Content) -> bool { + let text = collapse_whitespace(&extract::extract_text(inner)); + if text.is_empty() { + return false; + } + let Some(nearest) = stack.iter().rev().find_map(|open| open.par_text.as_deref()) else { + return false; + }; + collapse_whitespace(nearest).contains(&text) +} + +/// Whitespace-insensitive form, so that a line break introduced by layout in +/// one of the two texts does not defeat the containment test. +fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} diff --git a/crates/typst-cnd/src/emit/convert.rs b/crates/typst-cnd/src/emit/convert.rs index 2f1313374bf0..77ce9b3567e2 100644 --- a/crates/typst-cnd/src/emit/convert.rs +++ b/crates/typst-cnd/src/emit/convert.rs @@ -16,6 +16,7 @@ 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::model::{CndNode, HeadingNode}; @@ -517,6 +518,7 @@ 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); @@ -617,12 +619,30 @@ pub fn convert_from_introspector( for elem in introspector.query(&ParElem::ELEM.select()) { let Some(par) = elem.to_packed::() else { continue }; let Some(loc) = elem.location() else { continue }; + // A paragraph whose text an enclosing paragraph already carries is + // the body of an inline `box`; emitting it would duplicate the text. + if ancestry.is_covered_by_par(loc) { + 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(); 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/world.rs b/crates/typst-cnd/src/world.rs index c397583edef4..3fd76c5823e4 100644 --- a/crates/typst-cnd/src/world.rs +++ b/crates/typst-cnd/src/world.rs @@ -8,7 +8,7 @@ use typst::foundations::{Bytes, Datetime, Duration}; use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; -use typst::{Library, LibraryExt, World}; +use typst::{Feature, Features, Library, LibraryExt, World}; use crate::model::SourceInfo; use typst_kit::datetime::Time; @@ -27,7 +27,13 @@ pub struct CndWorld { } fn build_library() -> Library { - let mut library = Library::builder().build(); + // `Feature::CndSemantics` makes every fully-inline fragment body (a + // `block[…]`, a grid cell, a `place`, a `box`) realize into a real, + // locatable `ParElem`. Without it that text is laid out but never + // located, so `Introspector::query` — the only source the CND emit + // pipeline reads — cannot see it and the content is silently dropped. + let features = Features::from_iter([Feature::CndSemantics]); + let mut library = Library::builder().with_features(features).build(); // DEPRECATED — see crate::cnd's module doc. Removal tracked for the next release. library.global.scope_mut().define("cnd", crate::cnd::module()); library diff --git a/crates/typst-cnd/tests/document_suite.rs b/crates/typst-cnd/tests/document_suite.rs index c95be7a0f718..116992f4b6c1 100644 --- a/crates/typst-cnd/tests/document_suite.rs +++ b/crates/typst-cnd/tests/document_suite.rs @@ -394,15 +394,28 @@ fn newsletter_dashing_dept_news_template() { let sixtus = find_heading(&cnd.nodes, "Sixtus Award").expect("sixtus heading"); let CndNode::Heading(sixtus) = sixtus else { panic!() }; - assert!( - sixtus - .children - .iter() - .filter(|c| matches!(c, CndNode::Paragraph(_))) - .count() - >= 5, - "award section should contain multiple paragraphs" + let sixtus_paragraphs: Vec<&str> = sixtus + .children + .iter() + .filter_map(|c| match c { + CndNode::Paragraph(p) => Some(p.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + sixtus_paragraphs.len(), + 4, + "award section has exactly four prose paragraphs, got {sixtus_paragraphs:?}" ); + // The quote's attribution and the figure's caption belong to the + // QuoteNode and FigureNode respectively; they must not also surface as + // paragraphs of the section. + for leaked in ["Prof. Herzog", "Our new department rectangle"] { + assert!( + !sixtus_paragraphs.iter().any(|p| p.contains(leaked)), + "container content {leaked:?} leaked into a paragraph: {sixtus_paragraphs:?}" + ); + } } #[test] @@ -1197,3 +1210,64 @@ fn heading_show_rules_do_not_duplicate_headings_as_paragraphs() { ); } } + +/// Text carried by a layout-only container — a `block`, a grid cell, a +/// `place`, a user function's output — must reach the CND. +/// +/// Typst does not build a `ParElem` for a fragment body that is entirely +/// inline, so such text is laid out but never located, and the emit pipeline +/// (which reads `Introspector::query`) cannot see it. `Feature::CndSemantics` +/// forces the paragraph to exist; `emit::ancestry` then keeps the extra +/// paragraphs that creates from duplicating text that is already emitted. +#[test] +fn layout_container_content_is_not_lost() { + let cnd = cnd_for_example("layout_containers.typ"); + let paragraphs = paragraph_texts_in_order(&cnd.nodes); + let joined = paragraphs.join("\n"); + + for expected in [ + // `block(..)[..]` emitted by a user function, both of whose blocks + // hold nothing but inline content. + "KICKER LINE", + "Title inside a block", + // A card: the title, then one block per item, inside a grid cell. + "LEFT CARD", + "Left item one", + "Left item two", + "RIGHT CARD", + "Right item one", + "Right item two", + // A `place`d block. + "Placed punch line at the bottom.", + ] { + assert!( + paragraphs.iter().any(|p| p.contains(expected)), + "container content {expected:?} missing from the CND, got {paragraphs:?}" + ); + } + + // An inline `box` is part of its enclosing paragraph, which already + // renders its text — it must not also become a paragraph of its own. + for chip in ["FIRST CHIP", "SECOND CHIP"] { + assert_eq!( + joined.matches(chip).count(), + 1, + "inline box content {chip:?} was emitted more than once: {paragraphs:?}" + ); + } + + // Dedup keys off the *nearest* enclosing paragraph only, and must not + // swallow words the surrounding sentence uses in its own right. + assert!( + paragraphs + .iter() + .any(|p| p == "A paragraph where idem repeats a word the sentence itself uses: idem."), + "a box whose text recurs in its own sentence lost content, got {paragraphs:?}" + ); + + // A running footer is page furniture, repeated on every page. + assert!( + !joined.contains("RUNNING FOOTER"), + "page furniture leaked into the document body: {paragraphs:?}" + ); +} diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index 21d733e0016d..03976c893eef 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -274,10 +274,28 @@ pub enum Feature { Html, Bundle, A11yExtras, + /// Fork-local (CND export): force every fully-inline fragment body to be + /// realized into a real `ParElem` instead of being flattened into loose + /// inline content. + /// + /// Typst normally skips building a `ParElem` when a fragment (the body of + /// a `block`, a grid cell, a `place`, a `box`, …) turns out to be entirely + /// inline — see `is_fully_inline_or_neutral` in `typst-realize`. Such text + /// is laid out correctly but never gets a `Location`, so it is invisible + /// to `Introspector::query` and therefore missing from the CND export. + /// + /// This is deliberately **not** listed in [`Feature::all`]: unlike the + /// other variants it is not a feature to trial, it changes layout (an + /// inline fragment becomes `FlowMode::Block`) and must never be switched + /// on for PDF/HTML/SVG output. It is enabled only by `typst-cnd`'s own + /// world, whose sole output is the CND semantic tree. + CndSemantics, } impl Feature { /// Iterates over all available features. + /// + /// Excludes [`Feature::CndSemantics`] on purpose — see its docs. pub fn all() -> impl Iterator { [Self::Html, Self::Bundle, Self::A11yExtras].into_iter() } diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index fd30b3eb599a..00eb25b8e875 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -14,6 +14,8 @@ use bumpalo::collections::{CollectIn, String as BumpString, Vec as BumpVec}; use comemo::Track; use ecow::EcoString; use typst_html::HtmlElem; +use typst_library::Feature; +use typst_library::World; use typst_library::diag::{At, SourceResult, bail, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ @@ -1171,6 +1173,13 @@ fn in_non_par_grouping(s: &mut State) -> bool { /// spans the whole sink (with the exception of leading tags and neutral /// elements). fn is_fully_inline_or_neutral(s: &State) -> bool { + // CND export needs every fragment body to become a real, locatable + // `ParElem`; flattening it to loose inline content makes the text + // invisible to `Introspector::query`. See `Feature::CndSemantics`. + if s.engine.world.library().features.is_enabled(Feature::CndSemantics) { + return false; + } + if let RealizationKind::Fragment { .. } = s.kind && !s.saw_parbreak && let [grouping] = s.groupings.as_slice() From b3d878f95a3923d06bc4827454dcdd744985e325 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Thu, 17 Sep 2026 21:55:20 +0200 Subject: [PATCH 2/9] fix(cnd): separate the words a hard line break falls between MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard line break (`\`) renders as a new line but carries no text of its own, and `extract::walk` emitted nothing for it, so the words on either side were run together in a node's `text`: the title `Plateforme de calcul \ de coût véhicule` exported as "Plateforme de calculde coût véhicule". A node's `text` is a single flat string, and `\` advances the line without ending the paragraph, so the break becomes a space rather than a newline — a newline would suggest a separation Typst does not make. The separator is added during the same walk that computes marker offsets, so citation, footnote and reference spans stay consistent with it. Co-Authored-By: Claude Opus 5 --- crates/typst-cnd/examples/layout_containers.typ | 2 ++ crates/typst-cnd/src/emit/extract.rs | 13 +++++++++++++ crates/typst-cnd/tests/document_suite.rs | 7 +++++++ 3 files changed, 22 insertions(+) diff --git a/crates/typst-cnd/examples/layout_containers.typ b/crates/typst-cnd/examples/layout_containers.typ index fc2d5d35a0e9..7e5d5722f499 100644 --- a/crates/typst-cnd/examples/layout_containers.typ +++ b/crates/typst-cnd/examples/layout_containers.typ @@ -43,3 +43,5 @@ A paragraph where #chip[idem] repeats a word the sentence itself uses: idem. ) #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/emit/extract.rs b/crates/typst-cnd/src/emit/extract.rs index 90bf016bcb18..0967c6142dcc 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. /// @@ -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); diff --git a/crates/typst-cnd/tests/document_suite.rs b/crates/typst-cnd/tests/document_suite.rs index 116992f4b6c1..7ab19a4cb0a5 100644 --- a/crates/typst-cnd/tests/document_suite.rs +++ b/crates/typst-cnd/tests/document_suite.rs @@ -1270,4 +1270,11 @@ fn layout_container_content_is_not_lost() { !joined.contains("RUNNING FOOTER"), "page furniture leaked into the document body: {paragraphs:?}" ); + + // A hard line break carries no text of its own; without a separator the + // words on either side of it run together. + assert!( + paragraphs.iter().any(|p| p.contains("first line second line")), + "hard line break did not separate the lines, got {paragraphs:?}" + ); } From 0d1e46ccc9941fad559076ed3809d76e78ebba43 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Thu, 17 Sep 2026 23:06:54 +0200 Subject: [PATCH 3/9] fix(cnd): drop the unused reading-order comparison helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compare_locations` had no caller: `emit` is a private module, so the `pub fn` was unreachable from outside the crate, and the doc comment's "useful for tests" never materialized into a test. Under the CI's `-D warnings` that is a hard error, which failed `cargo test --no-run` on all three platforms and the minimum-Rust-version check. `std::cmp::Ordering` was imported for its return type alone — `sort_by_reading_order` calls `.cmp()` without naming the type — so the import goes with it, or it would just trade one denied warning for another. The crate's clippy and rustfmt failures are untouched here: they predate this branch, are identical on `main`, and span nearly every file of `typst-cnd`. They belong in their own cleanup. Co-Authored-By: Claude Opus 5 --- crates/typst-cnd/src/emit/reading_order.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/crates/typst-cnd/src/emit/reading_order.rs b/crates/typst-cnd/src/emit/reading_order.rs index cd46a5c7b402..085d76cefad1 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; @@ -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::*; From 28193cd0cd0cf6208e48df56d623fe9008cae448 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Fri, 18 Sep 2026 00:16:17 +0200 Subject: [PATCH 4/9] chore(cnd): make typst-cnd pass the workspace clippy lints and rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate has never satisfied `[workspace.lints.clippy]`, so the `checks` job has failed on every commit since it landed — 57 clippy errors and 103 rustfmt hunks, none of them from the bug fix this branch carries. CI runs with `RUSTFLAGS=-Dwarnings`, which turns each one into a build failure, and that is what hid the rest of the job: the second clippy invocation (`--no-default-features`), `cargo fmt --check` and `cargo doc` had never actually run on this crate. All four pass now. Almost all of it is mechanical — `if` chains collapsed into `&&`, `0usize` separated, `Default::default()` named, redundant annotations dropped. Three places needed a decision: - `is_one` / `is_true` / `is_table_kind` keep their `&T` parameter, with an `allow`: serde's `skip_serializing_if` hands the predicate a reference, so taking the value does not typecheck there. - `list_item` dropped `ordered` and `styles`. They were only ever passed down the recursion: an unordered item carries no number, and its text comes from `extract_text`, which is style-free. - `iter_over_hash_type` is a real question, not noise. The passes that walk `ConvertContext::records` build edges in visit order and then dedup them first-wins, so a hash map's unspecified order was reaching the output. They now go through `records_sorted()`; the two lookups that returned the *first* record matching a label — arbitrary when two nodes share one — go through `find_by_label`, which is that same order. Verified beyond the suite (27 tests, green): the release binary compiles the 40 documents of the `nordwind-tech` corpus to byte-identical node counts and text lengths before and after. Co-Authored-By: Claude Opus 5 --- crates/typst-cnd/src/bin/typst-cnd.rs | 17 +- crates/typst-cnd/src/cnd.rs | 7 +- crates/typst-cnd/src/document.rs | 17 +- crates/typst-cnd/src/emit/ancestry.rs | 7 +- crates/typst-cnd/src/emit/code.rs | 3 +- crates/typst-cnd/src/emit/convert.rs | 254 +++++++++++++-------- crates/typst-cnd/src/emit/extract.rs | 34 ++- crates/typst-cnd/src/emit/figure.rs | 28 ++- crates/typst-cnd/src/emit/list.rs | 25 +- crates/typst-cnd/src/emit/math.rs | 2 +- crates/typst-cnd/src/emit/pools.rs | 50 ++-- crates/typst-cnd/src/emit/reading_order.rs | 12 +- crates/typst-cnd/src/emit/refs.rs | 151 ++++++------ crates/typst-cnd/src/emit/table.rs | 53 +++-- crates/typst-cnd/src/lib.rs | 6 +- crates/typst-cnd/src/location.rs | 21 +- crates/typst-cnd/src/metadata.rs | 6 +- crates/typst-cnd/src/model.rs | 5 + crates/typst-cnd/src/world.rs | 4 +- crates/typst-cnd/tests/common/mod.rs | 33 ++- crates/typst-cnd/tests/document_suite.rs | 185 ++++++++------- 21 files changed, 507 insertions(+), 413 deletions(-) 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 8316b822b9c9..3a6feee0ce59 100644 --- a/crates/typst-cnd/src/document.rs +++ b/crates/typst-cnd/src/document.rs @@ -13,7 +13,9 @@ 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)] @@ -103,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 { @@ -210,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 index bd7faaf9212d..312bdcbf0e24 100644 --- a/crates/typst-cnd/src/emit/ancestry.rs +++ b/crates/typst-cnd/src/emit/ancestry.rs @@ -21,8 +21,8 @@ use typst_library::introspection::{Location, Tag}; use typst_library::layout::{Frame, FrameItem}; use typst_library::math::EquationElem; use typst_library::model::{ - EnumElem, FigureElem, FootnoteEntry, HeadingElem, ListElem, ParElem, QuoteElem, TableElem, - TermsElem, + EnumElem, FigureElem, FootnoteEntry, HeadingElem, ListElem, ParElem, QuoteElem, + TableElem, TermsElem, }; use typst_library::pdf::ArtifactElem; use typst_library::text::RawElem; @@ -187,7 +187,8 @@ fn covered_by_enclosing_par(stack: &[Open], inner: &Content) -> bool { if text.is_empty() { return false; } - let Some(nearest) = stack.iter().rev().find_map(|open| open.par_text.as_deref()) else { + let Some(nearest) = stack.iter().rev().find_map(|open| open.par_text.as_deref()) + else { return false; }; collapse_whitespace(nearest).contains(&text) 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 77ce9b3567e2..51905f20ee67 100644 --- a/crates/typst-cnd/src/emit/convert.rs +++ b/crates/typst-cnd/src/emit/convert.rs @@ -4,12 +4,14 @@ 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; @@ -18,7 +20,9 @@ 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 @@ -78,6 +82,19 @@ 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 + /// order has to come from the data, not from the map. + 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); @@ -122,11 +139,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 { @@ -137,7 +150,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) { @@ -174,7 +190,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, + ); } } @@ -183,7 +204,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, + ); } } @@ -205,7 +231,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, + ); } } } @@ -218,7 +249,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, + ); } } } @@ -247,10 +283,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; @@ -258,10 +294,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) @@ -301,7 +334,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) { @@ -345,7 +382,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(), @@ -377,13 +417,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( @@ -397,9 +434,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::() @@ -416,9 +461,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) { @@ -492,22 +537,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); } } } @@ -524,7 +569,8 @@ pub fn convert_from_introspector( 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(); @@ -556,13 +602,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)); } @@ -578,7 +621,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 }; @@ -586,7 +635,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 }; @@ -673,11 +728,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::() { @@ -701,7 +758,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::() { @@ -773,16 +831,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); @@ -822,7 +880,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) } @@ -844,8 +903,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 { @@ -855,7 +916,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))); + } } } @@ -892,25 +955,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(()) }); @@ -927,16 +982,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(()) }); @@ -1018,7 +1070,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 0967c6142dcc..ab175ce07325 100644 --- a/crates/typst-cnd/src/emit/extract.rs +++ b/crates/typst-cnd/src/emit/extract.rs @@ -66,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, @@ -143,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; } @@ -163,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), @@ -200,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/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 085d76cefad1..9b208fd25452 100644 --- a/crates/typst-cnd/src/emit/reading_order.rs +++ b/crates/typst-cnd/src/emit/reading_order.rs @@ -26,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, + )) }); } @@ -36,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) diff --git a/crates/typst-cnd/src/emit/refs.rs b/crates/typst-cnd/src/emit/refs.rs index 9ff2f8e6338c..f458d58e496e 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,47 @@ fn walk_heading_ids(nodes: &[CndNode], out: &mut Vec) { } } +/// The id of the node carrying `label`, searched in id order so two nodes +/// sharing a label — ill-formed, but possible — always resolve the same way. +fn find_by_label(ctx: &ConvertContext, label: Label) -> Option { + ctx.records_sorted() + .into_iter() + .find(|(_, record)| record.label == Some(label)) + .map(|(id, _)| id) +} + 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); - } - - for (id, record) in &ctx.records { - if record.label.as_ref() == Some(label) { - return Some(*id); - } + return find_by_label(ctx, label).or_else(|| find_labeled_table(ctx)); } - None + find_by_label(ctx, label) } fn find_labeled_table(ctx: &ConvertContext) -> Option { @@ -271,10 +288,8 @@ fn last_paragraph_under_heading(nodes: &[CndNode], heading_id: Uuid) -> Option { - if walk(&n.children, target, last) { - return true; - } + CndNode::Heading(n) if walk(&n.children, target, last) => { + return true; } _ => {} } @@ -310,10 +325,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 +349,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 +359,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 +370,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