From 4c9e4ac298e0601170c9ff45485121aac85a8451 Mon Sep 17 00:00:00 2001 From: Nate Todd Date: Sun, 17 May 2026 12:27:27 -0400 Subject: [PATCH 1/2] Relayout in a loop until introspection stabilizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Engine was wired with EmptyIntrospector and laid out exactly once, so Typst's two-pass introspection — which resolves page counters, refs, the outline, and any raw_typst calling counter/here/query — could never see the document's actual positions. Page numbering rendered the literal pattern on every page, refs found nothing, and the outline was empty. Build the body and styles once, then run the same MAX_ITERS=5 convergence loop the Typst CLI uses (compile_impl in vendor/typst/crates/typst/src/lib.rs): each iteration feeds the previous PagedDocument's introspector into a fresh engine, stopping when comemo::Constraint validates against the new document's introspector. --- native/folio_nif/src/world.rs | 65 ++++++++++++++++++++++++----------- test/folio_test.exs | 14 ++++++++ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/native/folio_nif/src/world.rs b/native/folio_nif/src/world.rs index bb6044a..2e72ba4 100644 --- a/native/folio_nif/src/world.rs +++ b/native/folio_nif/src/world.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::{Arc, LazyLock, Mutex}; -use typst::comemo::{Track, TrackedMut}; +use typst::comemo::{Constraint, Track, TrackedMut}; use typst::diag::{FileError, FileResult}; use typst::engine::{Engine, Route, Sink, Traced}; use typst::foundations::{ - Bytes, Content, Context, Datetime, Derived, Duration, NativeElement, Smart, StyleChain, Styles, - Target, TargetElem, + Bytes, Content, Context, Datetime, Derived, Duration, NativeElement, Output, Smart, + StyleChain, Styles, Target, TargetElem, }; -use typst::introspection::EmptyIntrospector; +use typst::introspection::{EmptyIntrospector, Introspector, MAX_ITERS}; use typst::layout::{Abs, Margin, Sides}; use typst::layout::PageElem; use typst::loading::{DataSource, LoadSource, Loaded}; @@ -147,31 +147,56 @@ impl FolioWorld { } fn layout(&self, content: &[ExContent]) -> Result { - let mut sink = Sink::new(); - let introspector = EmptyIntrospector; let traced = Traced::default(); - - let mut engine = Engine { - routines: &typst::ROUTINES, - world: Track::track(self), - introspector: typst::utils::Protected::new(introspector.track()), - traced: traced.track(), - sink: sink.track_mut(), - route: Route::root(), + let empty = EmptyIntrospector; + + let mut build_sink = Sink::new(); + let (body, user_styles) = { + let mut engine = Engine { + routines: &typst::ROUTINES, + world: Track::track(self), + introspector: typst::utils::Protected::new(empty.track()), + traced: traced.track(), + sink: build_sink.track_mut(), + route: Route::root(), + }; + let body = build_content(&mut engine, content); + let mut styles = typst::foundations::Styles::new(); + apply_styles(&mut styles, &self.styles, &mut engine); + (body, styles) }; - let body = build_content(&mut engine, content); - let lib = &GLOBAL.library; let base = StyleChain::new(&lib.styles); - let mut user_styles = typst::foundations::Styles::new(); - apply_styles(&mut user_styles, &self.styles, &mut engine); let target_style: Styles = TargetElem::target.set(Target::Paged).wrap().into(); let chained = base.chain(&target_style); let styles = chained.chain(&user_styles); - layout_document(&mut engine, &body, styles) - .map_err(|e| format!("Layout error: {:?}", e)) + let mut prev: Option = None; + for _ in 0..MAX_ITERS { + let constraint = Constraint::new(); + let introspector: &dyn Introspector = match &prev { + Some(doc) => Output::introspector(doc), + None => &empty, + }; + let mut iter_sink = Sink::new(); + let mut engine = Engine { + routines: &typst::ROUTINES, + world: Track::track(self), + introspector: typst::utils::Protected::new(introspector.track_with(&constraint)), + traced: traced.track(), + sink: iter_sink.track_mut(), + route: Route::root(), + }; + let doc = layout_document(&mut engine, &body, styles) + .map_err(|e| format!("Layout error: {:?}", e))?; + drop(engine); + if constraint.validate(Output::introspector(&doc)) { + return Ok(doc); + } + prev = Some(doc); + } + prev.ok_or_else(|| "Layout error: introspection did not converge".to_string()) } pub fn eval_math(engine: &mut Engine, math_str: &str, block: bool) -> Content { diff --git a/test/folio_test.exs b/test/folio_test.exs index 3a9a5ee..f7c4dfa 100644 --- a/test/folio_test.exs +++ b/test/folio_test.exs @@ -136,6 +136,20 @@ defmodule FolioTest do assert is_binary(svg) assert String.starts_with?(svg, "= 2 + # Body content should be the same, but page numbering should differ + assert page1 != page2 + end end describe "to_png/2" do From f1a08f1fa3242b1e1dcc3fc00c0894ebcecf1f7e Mon Sep 17 00:00:00 2001 From: Nate Todd Date: Sun, 17 May 2026 12:27:36 -0400 Subject: [PATCH 2/2] Attach labels to the preceding labellable element label/1 produced Content::empty().labelled(lbl), an empty placeholder carrying the label. Refs then couldn't find any numbered element, so [heading(1, "Foo"), label("foo")] followed by ref("foo") resolved to nothing. Mirror Typst's markup eval (vendor/typst/crates/typst-eval/src/markup.rs): when a Label node is built, walk backwards through the current sequence to the last non-Unlabellable element and attach the label there. Applied in both build_content and cc so labels inside nested bodies behave the same. Also suppress the auto-parbreak that would otherwise sit between a block and its trailing label and sever the attachment. --- native/folio_nif/src/convert.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/native/folio_nif/src/convert.rs b/native/folio_nif/src/convert.rs index 20a4c40..e5bc119 100644 --- a/native/folio_nif/src/convert.rs +++ b/native/folio_nif/src/convert.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use ecow::{eco_format, EcoString}; use typst::engine::Engine; -use typst::foundations::{Bytes, Content, NativeElement, OneOrMultiple, Smart}; +use typst::foundations::{Bytes, Content, NativeElement, OneOrMultiple, Smart, Unlabellable}; use std::sync::Arc; use typst::layout::{ Abs, AlignElem, Alignment, Axes, BlockBody, BlockElem, Celled, ColbreakElem, @@ -232,6 +232,7 @@ pub fn build_content(engine: &mut Engine, nodes: &[ExContent]) -> Content { (ExContent::Parbreak(_), _) | (_, ExContent::Parbreak(_)) => false, (ExContent::Pagebreak(_), _) | (_, ExContent::Pagebreak(_)) => false, (ExContent::Colbreak(_), _) | (_, ExContent::Colbreak(_)) => false, + (_, ExContent::Label(_)) => false, // Between two paragraph-like blocks (p, n) if is_paragraph_like(p) && is_paragraph_like(n) => true, // After a paragraph-like block and before another block @@ -244,11 +245,23 @@ pub fn build_content(engine: &mut Engine, nodes: &[ExContent]) -> Content { seq.push(ParbreakElem::shared().clone()); } } - seq.push(convert_node(engine, node)); + push_or_attach_label(engine, &mut seq, node); } Content::sequence(seq) } +fn push_or_attach_label(engine: &mut Engine, seq: &mut Vec, node: &ExContent) { + if let ExContent::Label(label) = node { + if let Some(lbl) = typst::foundations::Label::new(PicoStr::intern(&label.name)) { + if let Some(elem) = seq.iter_mut().rev().find(|n| !n.can::()) { + *elem = std::mem::take(elem).labelled(lbl); + return; + } + } + } + seq.push(convert_node(engine, node)); +} + fn is_block(node: &ExContent) -> bool { match node { ExContent::Heading(_) | ExContent::Paragraph(_) | ExContent::List(_) @@ -275,7 +288,11 @@ fn is_paragraph_like(node: &ExContent) -> bool { } fn cc(engine: &mut Engine, nodes: &[ExContent]) -> Content { - Content::sequence(nodes.iter().map(|n| convert_node(engine, n)).collect::>()) + let mut seq: Vec = Vec::with_capacity(nodes.len()); + for n in nodes { + push_or_attach_label(engine, &mut seq, n); + } + Content::sequence(seq) } fn convert_node(engine: &mut Engine, node: &ExContent) -> Content {