From 838a46dbb7124125947bfdafe8ddf97810c5de47 Mon Sep 17 00:00:00 2001 From: Laurenz Stampfl <47084093+LaurenzV@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:59:32 +0100 Subject: [PATCH 01/31] Test all exif rotation types and fix two of them (#6102) --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../src/visualize/image/raster.rs | 4 ++-- tests/ref/image-exif-rotation.png | Bin 0 -> 1392 bytes tests/ref/issue-870-image-rotation.png | Bin 200 -> 0 bytes tests/suite/visualize/image.typ | 19 ++++++++++++------ 6 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 tests/ref/image-exif-rotation.png delete mode 100644 tests/ref/issue-870-image-rotation.png diff --git a/Cargo.lock b/Cargo.lock index d63cec880..630eade2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2803,7 +2803,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=9879589#9879589f4b3247b12c5e694d0d7fa86d4d8a198e" +source = "git+https://github.com/typst/typst-dev-assets?rev=fddbf8b#fddbf8b99506bc370ac0edcd4959add603a7fc92" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index a14124d65..a73241832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } typst-assets = { git = "https://github.com/typst/typst-assets", rev = "ab1295f" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "9879589" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 0883fe71d..453b94066 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -325,12 +325,12 @@ fn apply_rotation(image: &mut DynamicImage, rotation: u32) { ops::flip_horizontal_in_place(image); *image = image.rotate270(); } - 6 => *image = image.rotate90(), + 6 => *image = image.rotate270(), 7 => { ops::flip_horizontal_in_place(image); *image = image.rotate90(); } - 8 => *image = image.rotate270(), + 8 => *image = image.rotate90(), _ => {} } } diff --git a/tests/ref/image-exif-rotation.png b/tests/ref/image-exif-rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..a319a5c5b446afa08c7ec2084f2d1965c1405607 GIT binary patch literal 1392 zcmV-$1&{iPP)^-HEZ{nY`bQwA_ZT&Q_1tbE48? znBbGR>9Wk*f~@4B!NDnV%1DOELW0RegV0)%%~Fl=&)@m$^7Gc@(`A|6in7Q+f!1)K z&sdS!dZ)`vh|N%o-i))pA8XQNnfdAP%S?&RRgdbo&)kHr_v7r~l(_cZ>Cjx0_~q`> zVVCmK;nZrJd)o!2e!q@xo_4L@~%~6cMA#C&2 z*m$P=_4wzk$kS(< z#>U3!v(3lH$L+z^UteF_fU8kPR8QV_!%zH`B}w-S>}l_UUhLpKSJaFEb%LJUl!+ zJQJ6q_!vmMo>&pV#3#f;67ZK@F0KVsOj24Vj_7~|*(w(qIab(R9n(r;Rzj;6SbVOK8Sx%&|7HnlOV zWjWd1%GMn|0%M%KKyKWONP6YLL$?|B{6%^Be1^S#^|Hzc7sQ!b2{)B9blpPa0|YcS5sq8i@?aV=6`)Lqr;<33EN4hrX)nl$OUAV%$*D zW1~N#uwZ*ko3zX8_4~a(pHD6ACT@>&WMo8PwBhtntd^#o0)4%%@Y1e-&9qCC^nlfB z>*?-wdsNKZZ}02uVn6n-&OUp;S5}zE-P_$`vswqjO8aF)Zr(H+g#}u9xdz#`p}*mjW&sTne~kB9P9ae+DHNrqVf z27RL{?YGGp#G#gUkPO-84+*E{TFK*cL3Zea7V$NC#ur8*W#XxdpGX!QI*D1 z4NpHjCGj-Xoisj=;B$-ar18lQpAe%g9XB(sgE&yyK|1KaRCT}dSH{1&aA_{BQM!Hj y8)R5dtes#!&iD?Qt|#`M|9*johlht};_?GLgY!0Dq{rU?0000F!$v^BJ8WOKO3hZ{* zmD@UlOU(EH<6qshG)CrK(Pu0i?9!4vqHSu`1TNf85^K{H5c{he$G@cR??2!C?wOLe z*2(Vr`~Uudl;sEh?`J=dq*m7U;eS2bx#P-eeUTDojG7Dfe);~f>%aZ~fASpN;f%@^ zSCVc;=e*C@|NsB|i_6ZhIJ#J@KguS{P>+G(z@>Nge%mrGft={+>gTe~DWM4f<3&-k diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 7ce0c8c0a..9a77870af 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -247,12 +247,6 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B format: "rgba8", ) ---- issue-870-image-rotation --- -// Ensure that EXIF rotation is applied. -// https://github.com/image-rs/image/issues/1045 -// File is from https://magnushoff.com/articles/jpeg-orientation/ -#image("/assets/images/f2t.jpg", width: 10pt) - --- issue-measure-image --- // Test that image measurement doesn't turn `inf / some-value` into 0pt. #context { @@ -267,3 +261,16 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- issue-3733-dpi-svg --- #set page(width: 200pt, height: 200pt, margin: 0pt) #image("/assets/images/relative.svg") + +--- image-exif-rotation --- +#let data = read("/assets/images/f2t.jpg", encoding: none) + +#let rotations = range(1, 9) +#let rotated(v) = image(data.slice(0, 49) + bytes((v,)) + data.slice(50), width: 10pt) + +#set page(width: auto) +#table( + columns: rotations.len(), + ..rotations.map(v => raw(str(v), lang: "typc")), + ..rotations.map(rotated) +) From b7a4382a73e495cf56350c1ba4f216d51b1864c7 Mon Sep 17 00:00:00 2001 From: Philipp Niedermayer Date: Fri, 28 Mar 2025 16:28:03 +0100 Subject: [PATCH 02/31] Fix typo (#6104) --- crates/typst-library/src/visualize/shape.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index 439b4cd98..ff05be2be 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -106,7 +106,7 @@ pub struct RectElem { pub radius: Corners>>, /// How much to pad the rectangle's content. - /// See the [box's documentation]($box.outset) for more details. + /// See the [box's documentation]($box.inset) for more details. #[resolve] #[fold] #[default(Sides::splat(Some(Abs::pt(5.0).into())))] From 20ee446ebab2fbb23246026301e26b82647369a2 Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:30:30 +0100 Subject: [PATCH 03/31] Fix descriptions of color maps (#6096) --- crates/typst-library/src/visualize/color.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index 20b0f5719..24d8305cd 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -148,11 +148,11 @@ static TO_SRGB: LazyLock = LazyLock::new(|| { /// | `magma` | A black to purple to yellow color map. | /// | `plasma` | A purple to pink to yellow color map. | /// | `rocket` | A black to red to white color map. | -/// | `mako` | A black to teal to yellow color map. | +/// | `mako` | A black to teal to white color map. | /// | `vlag` | A light blue to white to red color map. | -/// | `icefire` | A light teal to black to yellow color map. | +/// | `icefire` | A light teal to black to orange color map. | /// | `flare` | A orange to purple color map that is perceptually uniform. | -/// | `crest` | A blue to white to red color map. | +/// | `crest` | A light green to blue color map. | /// /// Some popular presets are not included because they are not available under a /// free licence. Others, like From efdb75558f20543af39f75fb88b3bae59b20e2e8 Mon Sep 17 00:00:00 2001 From: Matt Fellenz Date: Fri, 28 Mar 2025 18:33:16 +0100 Subject: [PATCH 04/31] IDE: complete jump-to-cursor impl (#6037) --- crates/typst-ide/src/jump.rs | 152 ++++++++++++++++++-- crates/typst-library/src/visualize/curve.rs | 64 +++++++++ 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index 428335426..b29bc4a48 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -3,7 +3,7 @@ use std::num::NonZeroUsize; use typst::layout::{Frame, FrameItem, PagedDocument, Point, Position, Size}; use typst::model::{Destination, Url}; use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind}; -use typst::visualize::Geometry; +use typst::visualize::{Curve, CurveItem, FillRule, Geometry}; use typst::WorldExt; use crate::IdeWorld; @@ -53,10 +53,20 @@ pub fn jump_from_click( for (mut pos, item) in frame.items().rev() { match item { FrameItem::Group(group) => { - // TODO: Handle transformation. - if let Some(span) = - jump_from_click(world, document, &group.frame, click - pos) - { + let pos = click - pos; + if let Some(clip) = &group.clip { + if !clip.contains(FillRule::NonZero, pos) { + continue; + } + } + // Realistic transforms should always be invertible. + // An example of one that isn't is a scale of 0, which would + // not be clickable anyway. + let Some(inv_transform) = group.transform.invert() else { + continue; + }; + let pos = pos.transform_inf(inv_transform); + if let Some(span) = jump_from_click(world, document, &group.frame, pos) { return Some(span); } } @@ -94,9 +104,32 @@ pub fn jump_from_click( } FrameItem::Shape(shape, span) => { - let Geometry::Rect(size) = shape.geometry else { continue }; - if is_in_rect(pos, size, click) { - return Jump::from_span(world, *span); + if shape.fill.is_some() { + let within = match &shape.geometry { + Geometry::Line(..) => false, + Geometry::Rect(size) => is_in_rect(pos, *size, click), + Geometry::Curve(curve) => { + curve.contains(shape.fill_rule, click - pos) + } + }; + if within { + return Jump::from_span(world, *span); + } + } + + if let Some(stroke) = &shape.stroke { + let within = !stroke.thickness.approx_empty() && { + // This curve is rooted at (0, 0), not `pos`. + let base_curve = match &shape.geometry { + Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]), + Geometry::Rect(size) => &Curve::rect(*size), + Geometry::Curve(curve) => curve, + }; + base_curve.stroke_contains(stroke, click - pos) + }; + if within { + return Jump::from_span(world, *span); + } } } @@ -146,9 +179,8 @@ pub fn jump_from_cursor( fn find_in_frame(frame: &Frame, span: Span) -> Option { for (mut pos, item) in frame.items() { if let FrameItem::Group(group) = item { - // TODO: Handle transformation. if let Some(point) = find_in_frame(&group.frame, span) { - return Some(point + pos); + return Some(pos + point.transform(group.transform)); } } @@ -269,6 +301,97 @@ mod tests { test_click("$a + b$", point(28.0, 14.0), cursor(5)); } + #[test] + fn test_jump_from_click_transform_clip() { + let margin = point(10.0, 10.0); + test_click( + "#rect(width: 20pt, height: 20pt, fill: black)", + point(10.0, 10.0) + margin, + cursor(1), + ); + test_click( + "#rect(width: 60pt, height: 10pt, fill: black)", + point(5.0, 30.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))", + point(5.0, 30.0) + margin, + cursor(38), + ); + test_click( + "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \ + origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left)[hello world]", + point(5.0, 15.0) + margin, + cursor(40), + ); + } + + #[test] + fn test_jump_from_click_shapes() { + let margin = point(10.0, 10.0); + + test_click( + "#rect(width: 30pt, height: 30pt, fill: black)", + point(15.0, 15.0) + margin, + cursor(1), + ); + + let circle = "#circle(width: 30pt, height: 30pt, fill: black)"; + test_click(circle, point(15.0, 15.0) + margin, cursor(1)); + test_click(circle, point(1.0, 1.0) + margin, None); + + let bowtie = + "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))"; + test_click(bowtie, point(1.0, 2.0) + margin, cursor(1)); + test_click(bowtie, point(2.0, 1.0) + margin, None); + test_click(bowtie, point(19.0, 10.0) + margin, cursor(1)); + + let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd", + (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt), + (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt), + (20pt, 20pt), (0pt, 20pt))"#; + test_click(evenodd, point(15.0, 15.0) + margin, None); + test_click(evenodd, point(5.0, 15.0) + margin, cursor(1)); + test_click(evenodd, point(15.0, 5.0) + margin, cursor(1)); + } + + #[test] + fn test_jump_from_click_shapes_stroke() { + let margin = point(10.0, 10.0); + + let rect = + "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))"; + test_click(rect, point(15.0, 15.0) + margin, None); + test_click(rect, point(10.0, 15.0) + margin, cursor(27)); + + test_click( + "#line(angle: 45deg, length: 10pt, stroke: 2pt)", + point(2.0, 2.0) + margin, + cursor(1), + ); + } + #[test] fn test_jump_from_cursor() { let s = "*Hello* #box[ABC] World"; @@ -281,6 +404,15 @@ mod tests { test_cursor("$a + b$", -3, pos(1, 27.51, 16.83)); } + #[test] + fn test_jump_from_cursor_transform() { + test_cursor( + r#"#rotate(90deg, origin: bottom + left, [hello world])"#, + -5, + pos(1, 10.0, 16.58), + ); + } + #[test] fn test_backlink() { let s = "#footnote[Hi]"; diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index fb5151e8f..50944a516 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -10,6 +10,8 @@ use crate::foundations::{ use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size}; use crate::visualize::{FillRule, Paint, Stroke}; +use super::FixedStroke; + /// A curve consisting of movements, lines, and Bézier segments. /// /// At any point in time, there is a conceptual pen or cursor. @@ -530,3 +532,65 @@ impl Curve { Size::new(max_x - min_x, max_y - min_y) } } + +impl Curve { + fn to_kurbo(&self) -> impl Iterator + '_ { + use kurbo::PathEl; + + self.0.iter().map(|item| match *item { + CurveItem::Move(point) => PathEl::MoveTo(point_to_kurbo(point)), + CurveItem::Line(point) => PathEl::LineTo(point_to_kurbo(point)), + CurveItem::Cubic(point, point1, point2) => PathEl::CurveTo( + point_to_kurbo(point), + point_to_kurbo(point1), + point_to_kurbo(point2), + ), + CurveItem::Close => PathEl::ClosePath, + }) + } + + /// When this curve is interpreted as a clip mask, would it contain `point`? + pub fn contains(&self, fill_rule: FillRule, needle: Point) -> bool { + let kurbo = kurbo::BezPath::from_vec(self.to_kurbo().collect()); + let windings = kurbo::Shape::winding(&kurbo, point_to_kurbo(needle)); + match fill_rule { + FillRule::NonZero => windings != 0, + FillRule::EvenOdd => windings % 2 != 0, + } + } + + /// When this curve is stroked with `stroke`, would the stroke contain + /// `point`? + pub fn stroke_contains(&self, stroke: &FixedStroke, needle: Point) -> bool { + let width = stroke.thickness.to_raw(); + let cap = match stroke.cap { + super::LineCap::Butt => kurbo::Cap::Butt, + super::LineCap::Round => kurbo::Cap::Round, + super::LineCap::Square => kurbo::Cap::Square, + }; + let join = match stroke.join { + super::LineJoin::Miter => kurbo::Join::Miter, + super::LineJoin::Round => kurbo::Join::Round, + super::LineJoin::Bevel => kurbo::Join::Bevel, + }; + let miter_limit = stroke.miter_limit.get(); + let mut style = kurbo::Stroke::new(width) + .with_caps(cap) + .with_join(join) + .with_miter_limit(miter_limit); + if let Some(dash) = &stroke.dash { + style = style.with_dashes( + dash.phase.to_raw(), + dash.array.iter().copied().map(Abs::to_raw), + ); + } + let opts = kurbo::StrokeOpts::default(); + let tolerance = 0.01; + let expanded = kurbo::stroke(self.to_kurbo(), &style, &opts, tolerance); + kurbo::Shape::contains(&expanded, point_to_kurbo(needle)) + } +} + +fn point_to_kurbo(point: Point) -> kurbo::Point { + kurbo::Point::new(point.x.to_raw(), point.y.to_raw()) +} From 758ee78ef57ebbaadacc50817620a540bcf8beeb Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:08:55 +0800 Subject: [PATCH 05/31] Make `World::font` implementations safe (#6117) --- crates/typst-cli/src/world.rs | 4 +++- crates/typst-ide/src/tests.rs | 2 +- docs/src/html.rs | 2 +- tests/src/world.rs | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 12e80d273..2da03d4d5 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -210,7 +210,9 @@ impl World for SystemWorld { } fn font(&self, index: usize) -> Option { - self.fonts[index].get() + // comemo's validation may invoke this function with an invalid index. This is + // impossible in typst-cli but possible if a custom tool mutates the fonts. + self.fonts.get(index)?.get() } fn today(&self, offset: Option) -> Option { diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 6678ab841..c6d733ca9 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -97,7 +97,7 @@ impl World for TestWorld { } fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) + self.base.fonts.get(index).cloned() } fn today(&self, _: Option) -> Option { diff --git a/docs/src/html.rs b/docs/src/html.rs index 9077d5c47..9c02f08e9 100644 --- a/docs/src/html.rs +++ b/docs/src/html.rs @@ -498,7 +498,7 @@ impl World for DocWorld { } fn font(&self, index: usize) -> Option { - Some(FONTS.1[index].clone()) + FONTS.1.get(index).cloned() } fn today(&self, _: Option) -> Option { diff --git a/tests/src/world.rs b/tests/src/world.rs index 9e0e91ad7..fe2bd45ea 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -67,7 +67,7 @@ impl World for TestWorld { } fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) + self.base.fonts.get(index).cloned() } fn today(&self, _: Option) -> Option { From 326bec1f0d0fc65fb26ae4c797487d82d2b18b81 Mon Sep 17 00:00:00 2001 From: Astra3 Date: Mon, 31 Mar 2025 10:16:47 +0200 Subject: [PATCH 06/31] Correcting Czech translation in `typst-library` (#6101) --- crates/typst-library/translations/cs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/translations/cs.txt b/crates/typst-library/translations/cs.txt index d4688ffee..e21ca3520 100644 --- a/crates/typst-library/translations/cs.txt +++ b/crates/typst-library/translations/cs.txt @@ -4,5 +4,5 @@ equation = Rovnice bibliography = Bibliografie heading = Kapitola outline = Obsah -raw = Seznam +raw = Výpis page = strana \ No newline at end of file From e60d3021a782c5977cf7de726682e19ae89abeb3 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 31 Mar 2025 04:17:37 -0400 Subject: [PATCH 07/31] Add env setting for ignore_system_fonts (#6092) --- crates/typst-cli/src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index d6855d100..76f647276 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -361,7 +361,7 @@ pub struct FontArgs { /// Ensures system fonts won't be searched, unless explicitly included via /// `--font-path`. - #[arg(long)] + #[arg(long, env = "TYPST_IGNORE_SYSTEM_FONTS")] pub ignore_system_fonts: bool, } From 1082181a6f789b73fbc64c4ff5bc1401ad081e76 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:01:01 +0200 Subject: [PATCH 08/31] Improve french smartquotes (#5976) --- crates/typst-library/src/text/smartquote.rs | 2 +- tests/ref/smartquote-disabled-temporarily.png | Bin 2781 -> 2782 bytes tests/ref/smartquote-fr.png | Bin 2344 -> 2334 bytes tests/ref/smartquote-with-embedding-chars.png | Bin 573 -> 568 bytes tests/suite/text/smartquote.typ | 2 +- 5 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index f457a6371..4dda689df 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -238,7 +238,7 @@ impl<'s> SmartQuotes<'s> { "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high, "da" => ("‘", "’", "“", "”"), "fr" | "ru" if alternative => default, - "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"), + "fr" => ("“", "”", "«\u{202F}", "\u{202F}»"), "fi" | "sv" if alternative => ("’", "’", "»", "»"), "bs" | "fi" | "sv" => ("’", "’", "”", "”"), "it" if alternative => default, diff --git a/tests/ref/smartquote-disabled-temporarily.png b/tests/ref/smartquote-disabled-temporarily.png index 4c565c01ce1d91bfbd8b443f410c56e0b9b55360..f4d08c4d12fd5a8ecbc19d2d3d5bb252b2011386 100644 GIT binary patch delta 2774 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 delta 2773 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 b{6PN!WoyUfJv0%b00000NkvXXu0mjf8my2m diff --git a/tests/ref/smartquote-fr.png b/tests/ref/smartquote-fr.png index e28184226ac174455d34de80f4ab329c305b7bfa..6b7de7abeecd29b66aaad7467f6a787d7fb7d7a7 100644 GIT binary patch delta 2323 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 t&81yhJU5m3P_CFiB(~{ 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 diff --git a/tests/suite/text/smartquote.typ b/tests/suite/text/smartquote.typ index 4940d11b2..f2af93ceb 100644 --- a/tests/suite/text/smartquote.typ +++ b/tests/suite/text/smartquote.typ @@ -99,7 +99,7 @@ He's told some books contain questionable "example text". --- smartquote-disabled-temporarily --- // Test changing properties within text. -"She suddenly started speaking french: #text(lang: "fr")['Je suis une banane.']" Roman told me. +"She suddenly started speaking french: #text(lang: "fr", region: "CH")['Je suis une banane.']" Roman told me. Some people's thought on this would be #[#set smartquote(enabled: false); "strange."] From a64af130dc84c84442d59f322b705bded28201de Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 31 Mar 2025 05:06:18 -0400 Subject: [PATCH 09/31] Add default parameter for array.{first, last} (#5970) --- crates/typst-library/src/foundations/array.rs | 24 ++++++++++++++----- tests/suite/foundations/array.typ | 4 ++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index e81b9e645..b647473ab 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -172,17 +172,29 @@ impl Array { } /// Returns the first item in the array. May be used on the left-hand side - /// of an assignment. Fails with an error if the array is empty. + /// an assignment. Returns the default value if the array is empty + /// or fails with an error is no default value was specified. #[func] - pub fn first(&self) -> StrResult { - self.0.first().cloned().ok_or_else(array_is_empty) + pub fn first( + &self, + /// A default value to return if the array is empty. + #[named] + default: Option, + ) -> StrResult { + self.0.first().cloned().or(default).ok_or_else(array_is_empty) } /// Returns the last item in the array. May be used on the left-hand side of - /// an assignment. Fails with an error if the array is empty. + /// an assignment. Returns the default value if the array is empty + /// or fails with an error is no default value was specified. #[func] - pub fn last(&self) -> StrResult { - self.0.last().cloned().ok_or_else(array_is_empty) + pub fn last( + &self, + /// A default value to return if the array is empty. + #[named] + default: Option, + ) -> StrResult { + self.0.last().cloned().or(default).ok_or_else(array_is_empty) } /// Returns the item at the specified index in the array. May be used on the diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ index 6228f471b..61b5decb3 100644 --- a/tests/suite/foundations/array.typ +++ b/tests/suite/foundations/array.typ @@ -179,6 +179,10 @@ #test((2,).last(), 2) #test((1, 2, 3).first(), 1) #test((1, 2, 3).last(), 3) +#test((1, 2).first(default: 99), 1) +#test(().first(default: 99), 99) +#test((1, 2).last(default: 99), 2) +#test(().last(default: 99), 99) --- array-first-empty --- // Error: 2-12 array is empty From 4f0fbfb7e003f6ae88c1b210fdb7b38f795fc9e4 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 31 Mar 2025 09:17:49 +0000 Subject: [PATCH 10/31] Add dotless parameter to `math.accent` (#5939) Co-authored-by: Laurenz --- crates/typst-layout/src/math/accent.rs | 6 +++-- crates/typst-library/src/math/accent.rs | 26 +++++++++++++++++++-- tests/ref/math-accent-dotless-disabled.png | Bin 0 -> 311 bytes tests/ref/math-accent-dotless-set-rule.png | Bin 0 -> 147 bytes tests/suite/math/accent.typ | 8 +++++++ 5 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/ref/math-accent-dotless-disabled.png create mode 100644 tests/ref/math-accent-dotless-set-rule.png diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index f2dfa2c45..73d821019 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -19,8 +19,10 @@ pub fn layout_accent( let mut base = ctx.layout_into_fragment(&elem.base, styles.chain(&cramped))?; // Try to replace a glyph with its dotless variant. - if let MathFragment::Glyph(glyph) = &mut base { - glyph.make_dotless_form(ctx); + if elem.dotless(styles) { + if let MathFragment::Glyph(glyph) = &mut base { + glyph.make_dotless_form(ctx); + } } // Preserve class to preserve automatic spacing. diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index b162c52b1..e62b63872 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -13,8 +13,8 @@ use crate::math::Mathy; /// ``` #[elem(Mathy)] pub struct AccentElem { - /// The base to which the accent is applied. - /// May consist of multiple letters. + /// The base to which the accent is applied. May consist of multiple + /// letters. /// /// ```example /// $arrow(A B C)$ @@ -51,9 +51,24 @@ pub struct AccentElem { pub accent: Accent, /// The size of the accent, relative to the width of the base. + /// + /// ```example + /// $dash(A, size: #150%)$ + /// ``` #[resolve] #[default(Rel::one())] pub size: Rel, + + /// Whether to remove the dot on top of lowercase i and j when adding a top + /// accent. + /// + /// This enables the `dtls` OpenType feature. + /// + /// ```example + /// $hat(dotless: #false, i)$ + /// ``` + #[default(true)] + pub dotless: bool, } /// An accent character. @@ -103,11 +118,18 @@ macro_rules! accents { /// The size of the accent, relative to the width of the base. #[named] size: Option>, + /// Whether to remove the dot on top of lowercase i and j when + /// adding a top accent. + #[named] + dotless: Option, ) -> Content { let mut accent = AccentElem::new(base, Accent::new($primary)); if let Some(size) = size { accent = accent.with_size(size); } + if let Some(dotless) = dotless { + accent = accent.with_dotless(dotless); + } accent.pack() } )+ diff --git a/tests/ref/math-accent-dotless-disabled.png b/tests/ref/math-accent-dotless-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..d75ec4580253f4b791384ba7e98bf209eab338c6 GIT binary patch literal 311 zcmV-70m%M|P)w)T5`r&Y`IJ`TyHLT3Y-rdKZLobSId4 z-n?iFMBq;YO@n&RpTm$K{`C_~z5IXo4^-+iO)dUB?-`iB2#e6G3nBF5`HyI7@rvb3 z!1T(HZ(#cNiR{N<#{B)gG`09&$Qwvp2SE6L|GPmMt$S%|@rU3O|6hPSxa1g+1_^}z ze+5p?KHq6-@tdMO{{yf7|G%c^;eX3_K!Ht@{x^X_|F+?005R4eF=Tc4^998002ov JPDHLkV1kDTnQ{OC literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-dotless-set-rule.png b/tests/ref/math-accent-dotless-set-rule.png new file mode 100644 index 0000000000000000000000000000000000000000..ae5ef017aaedc711d30182cd57d05de08e9f9397 GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^6+kS@0VEh)%)UPdQc<2Rjv*Ddl7HAcG$dYm6xi*q zE4Q^mLFeDx!h{5!dmrnL|8C@bb+w*NqtBl|Kt{gaz(6eCR>a;wY=5+2azm5Vk88gh oH(J#EV)*&v_4zopr0MHjU3IG5A literal 0 HcmV?d00001 diff --git a/tests/suite/math/accent.typ b/tests/suite/math/accent.typ index 5be4f576f..ab0078a5f 100644 --- a/tests/suite/math/accent.typ +++ b/tests/suite/math/accent.typ @@ -42,3 +42,11 @@ $tilde(U, size: #1.1em), x^tilde(U, size: #1.1em), sscript(tilde(U, size: #1.1em macron(bb(#c)), dot(cal(#c)), diaer(upright(#c)), breve(bold(#c)), circle(bold(upright(#c))), caron(upright(sans(#c))), arrow(bold(frak(#c)))$ $test(i) \ test(j)$ + +--- math-accent-dotless-disabled --- +// Test disabling the dotless glyph variants. +$hat(i), hat(i, dotless: #false), accent(j, tilde), accent(j, tilde, dotless: #false)$ + +--- math-accent-dotless-set-rule --- +#set math.accent(dotless: false) +$ hat(i) $ From 012e14d40cb44997630cf6469a446f217f2e9057 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 31 Mar 2025 09:38:04 +0000 Subject: [PATCH 11/31] Unify layout of `vec` and `cases` with `mat` (#5934) --- crates/typst-layout/src/math/mat.rs | 195 +++++++++++----------- crates/typst-layout/src/math/shared.rs | 16 +- crates/typst-layout/src/math/underover.rs | 10 +- tests/ref/math-cases-linebreaks.png | Bin 570 -> 506 bytes tests/ref/math-equation-font.png | Bin 984 -> 1032 bytes tests/ref/math-mat-vec-cases-unity.png | Bin 0 -> 1202 bytes tests/ref/math-vec-linebreaks.png | Bin 856 -> 651 bytes tests/suite/math/cases.typ | 4 +- tests/suite/math/mat.typ | 11 +- tests/suite/math/vec.typ | 4 +- 10 files changed, 118 insertions(+), 122 deletions(-) create mode 100644 tests/ref/math-mat-vec-cases-unity.png diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index bf4929026..d678f8658 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -1,4 +1,4 @@ -use typst_library::diag::{bail, SourceResult}; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; use typst_library::layout::{ Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, @@ -9,7 +9,7 @@ use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; use typst_syntax::Span; use super::{ - alignments, delimiter_alignment, stack, style_for_denominator, AlignmentResult, + alignments, delimiter_alignment, style_for_denominator, AlignmentResult, FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, DELIM_SHORT_FALL, }; @@ -23,67 +23,23 @@ pub fn layout_vec( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let delim = elem.delim(styles); - let frame = layout_vec_body( + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( ctx, styles, - &elem.children, + &[column], elem.align(styles), - elem.gap(styles), LeftRightAlternator::Right, + None, + Axes::with_y(elem.gap(styles)), + span, + "elements", )?; - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) -} - -/// Lays out a [`MatElem`]. -#[typst_macros::time(name = "math.mat", span = elem.span())] -pub fn layout_mat( - elem: &Packed, - ctx: &mut MathContext, - styles: StyleChain, -) -> SourceResult<()> { - let augment = elem.augment(styles); - let rows = &elem.rows; - - if let Some(aug) = &augment { - for &offset in &aug.hline.0 { - if offset == 0 || offset.unsigned_abs() >= rows.len() { - bail!( - elem.span(), - "cannot draw a horizontal line after row {} of a matrix with {} rows", - if offset < 0 { rows.len() as isize + offset } else { offset }, - rows.len() - ); - } - } - - let ncols = rows.first().map_or(0, |row| row.len()); - - for &offset in &aug.vline.0 { - if offset == 0 || offset.unsigned_abs() >= ncols { - bail!( - elem.span(), - "cannot draw a vertical line after column {} of a matrix with {} columns", - if offset < 0 { ncols as isize + offset } else { offset }, - ncols - ); - } - } - } - let delim = elem.delim(styles); - let frame = layout_mat_body( - ctx, - styles, - rows, - elem.align(styles), - augment, - Axes::new(elem.column_gap(styles), elem.row_gap(styles)), - elem.span(), - )?; - - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } /// Lays out a [`CasesElem`]. @@ -93,60 +49,100 @@ pub fn layout_cases( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let delim = elem.delim(styles); - let frame = layout_vec_body( + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( ctx, styles, - &elem.children, + &[column], FixedAlignment::Start, - elem.gap(styles), LeftRightAlternator::None, + None, + Axes::with_y(elem.gap(styles)), + span, + "branches", )?; + let delim = elem.delim(styles); let (open, close) = if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; - - layout_delimiters(ctx, styles, frame, open, close, elem.span()) + layout_delimiters(ctx, styles, frame, open, close, span) } -/// Layout the inner contents of a vector. -fn layout_vec_body( +/// Lays out a [`MatElem`]. +#[typst_macros::time(name = "math.mat", span = elem.span())] +pub fn layout_mat( + elem: &Packed, ctx: &mut MathContext, styles: StyleChain, - column: &[Content], - align: FixedAlignment, - row_gap: Rel, - alternator: LeftRightAlternator, -) -> SourceResult { - let gap = row_gap.relative_to(ctx.region.size.y); +) -> SourceResult<()> { + let span = elem.span(); + let rows = &elem.rows; + let ncols = rows.first().map_or(0, |row| row.len()); - let denom_style = style_for_denominator(styles); - let mut flat = vec![]; - for child in column { - // We allow linebreaks in cases and vectors, which are functionally - // identical to commas. - flat.extend(ctx.layout_into_run(child, styles.chain(&denom_style))?.rows()); + let augment = elem.augment(styles); + if let Some(aug) = &augment { + for &offset in &aug.hline.0 { + if offset == 0 || offset.unsigned_abs() >= rows.len() { + bail!( + span, + "cannot draw a horizontal line after row {} of a matrix with {} rows", + if offset < 0 { rows.len() as isize + offset } else { offset }, + rows.len() + ); + } + } + + for &offset in &aug.vline.0 { + if offset == 0 || offset.unsigned_abs() >= ncols { + bail!( + span, + "cannot draw a vertical line after column {} of a matrix with {} columns", + if offset < 0 { ncols as isize + offset } else { offset }, + ncols + ); + } + } } - // We pad ascent and descent with the ascent and descent of the paren - // to ensure that normal vectors are aligned with others unless they are - // way too big. - let paren = - GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); - Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent)))) + + // Transpose rows of the matrix into columns. + let mut row_iters: Vec<_> = rows.iter().map(|i| i.iter()).collect(); + let columns: Vec> = (0..ncols) + .map(|_| row_iters.iter_mut().map(|i| i.next().unwrap()).collect()) + .collect(); + + let frame = layout_body( + ctx, + styles, + &columns, + elem.align(styles), + LeftRightAlternator::Right, + augment, + Axes::new(elem.column_gap(styles), elem.row_gap(styles)), + span, + "cells", + )?; + + let delim = elem.delim(styles); + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } -/// Layout the inner contents of a matrix. -fn layout_mat_body( +/// Layout the inner contents of a matrix, vector, or cases. +#[allow(clippy::too_many_arguments)] +fn layout_body( ctx: &mut MathContext, styles: StyleChain, - rows: &[Vec], + columns: &[Vec<&Content>], align: FixedAlignment, + alternator: LeftRightAlternator, augment: Option>, gap: Axes>, span: Span, + children: &str, ) -> SourceResult { - let ncols = rows.first().map_or(0, |row| row.len()); - let nrows = rows.len(); + let nrows = columns.first().map_or(0, |col| col.len()); + let ncols = columns.len(); if ncols == 0 || nrows == 0 { return Ok(Frame::soft(Size::zero())); } @@ -178,16 +174,11 @@ fn layout_mat_body( // Before the full matrix body can be laid out, the // individual cells must first be independently laid out // so we can ensure alignment across rows and columns. + let mut cols = vec![vec![]; ncols]; // This variable stores the maximum ascent and descent for each row. let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; - // We want to transpose our data layout to columns - // before final layout. For efficiency, the columns - // variable is set up here and newly generated - // individual cells are then added to it. - let mut cols = vec![vec![]; ncols]; - let denom_style = style_for_denominator(styles); // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal matrices are aligned with others unless they are @@ -195,10 +186,22 @@ fn layout_mat_body( let paren = GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); - for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { - for (cell, col) in row.iter().zip(&mut cols) { + for (column, col) in columns.iter().zip(&mut cols) { + for (cell, (ascent, descent)) in column.iter().zip(&mut heights) { + let cell_span = cell.span(); let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?; + // We ignore linebreaks in the cells as we can't differentiate + // alignment points for the whole body from ones for a specific + // cell, and multiline cells don't quite make sense at the moment. + if cell.is_multiline() { + ctx.engine.sink.warn(warning!( + cell_span, + "linebreaks are ignored in {}", children; + hint: "use commas instead to separate each line" + )); + } + ascent.set_max(cell.ascent().max(paren.ascent)); descent.set_max(cell.descent().max(paren.descent)); @@ -222,7 +225,7 @@ fn layout_mat_body( let mut y = Abs::zero(); for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { - let cell = cell.into_line_frame(&points, LeftRightAlternator::Right); + let cell = cell.into_line_frame(&points, alternator); let pos = Point::new( if points.is_empty() { x + align.position(rcol - cell.width()) diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 5aebdacac..600c130d4 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -117,7 +117,6 @@ pub fn stack( gap: Abs, baseline: usize, alternator: LeftRightAlternator, - minimum_ascent_descent: Option<(Abs, Abs)>, ) -> Frame { let AlignmentResult { points, width } = alignments(&rows); let rows: Vec<_> = rows @@ -125,13 +124,9 @@ pub fn stack( .map(|row| row.into_line_frame(&points, alternator)) .collect(); - let padded_height = |height: Abs| { - height.max(minimum_ascent_descent.map_or(Abs::zero(), |(a, d)| a + d)) - }; - let mut frame = Frame::soft(Size::new( width, - rows.iter().map(|row| padded_height(row.height())).sum::() + rows.iter().map(|row| row.height()).sum::() + rows.len().saturating_sub(1) as f64 * gap, )); @@ -142,14 +137,11 @@ pub fn stack( } else { Abs::zero() }; - let ascent_padded_part = minimum_ascent_descent - .map_or(Abs::zero(), |(a, _)| (a - row.ascent())) - .max(Abs::zero()); - let pos = Point::new(x, y + ascent_padded_part); + let pos = Point::new(x, y); if i == baseline { - frame.set_baseline(y + row.baseline() + ascent_padded_part); + frame.set_baseline(y + row.baseline()); } - y += padded_height(row.height()) + gap; + y += row.height() + gap; frame.push_frame(pos, row); } diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs index 7b3617c3e..5b6bd40eb 100644 --- a/crates/typst-layout/src/math/underover.rs +++ b/crates/typst-layout/src/math/underover.rs @@ -312,14 +312,8 @@ fn layout_underoverspreader( } }; - let frame = stack( - rows, - FixedAlignment::Center, - gap, - baseline, - LeftRightAlternator::Right, - None, - ); + let frame = + stack(rows, FixedAlignment::Center, gap, baseline, LeftRightAlternator::Right); ctx.push(FrameFragment::new(styles, frame).with_class(body_class)); Ok(()) diff --git a/tests/ref/math-cases-linebreaks.png b/tests/ref/math-cases-linebreaks.png index 543d5384c11a270a8a56f95e91e4f5ec7ac64d3f..eb4971c46fb2d2a36a8b95324d3d1e08b7d99319 100644 GIT binary patch delta 481 zcmV<70UrLk1o{JzBYy$GNklVP4Bt@3Ph~{FOY>~-6-Ym~#Fk@(%=gn7d8s##_|N9-h|Ka!jzuw_< z_^dgPGQ4DaHy6DPTURhgFj~rC+X$d z*Zcb|6!U@Jf`%y^^#b7U_EF#qbX=t@;hh41RPS2=ydL}DDms+_!Qf*H+cWR{nR?pA zq`(??MII3`luOhtmxVHZX&+S@xjHmcBS8ICMZZCNQe*yTvW{Iao?K$2M~*ie0O?xd zfm<&i8Ue;I67~?JUDnVX5>L}krf~T9e>)`Nb!69DT3Yx)FDVSiZpn3kTjVGv*L@b; z9);nE831eTfQMp?-OV?nMup+DZMjN3pfFt9L$sWP6f+Hsn<5lQAABqA!TMHEz9cRe8q zCP#{hjKH8aMcJl}i-<6+$P9wogh3C29$-Qlq}1S~m52`p2z#sqtf$p4{Ie*W^1g3T14$a%T?k;imS}Xu*AxThl5vuxwIhJd)kH^6!2pL7V0t2{+Kd2Rt|zK1 zybSY`$6yGPRKJY2V+%JoeQ>bv>DrQZjCWjjc|X8&ESNzC7Y7YIRV(jmr_iptIA~YB z-6rqaw$XMR?0-%H7M)9VA)QztpfLkw!h6e6LH9V$hchRamc!{O9Vd7bc z>fVN>mR(XB%vvNLd=(mCZyh=!Qns)PgX2aQ^RQ|gV6G-xUS(@I1Tf56%Ny$~&Kro0$&|(nW5as&2^UIJ kA*{Z0sT*Jwtb$SJ2Wbl+-x_NR>i_@%07*qoM6N<$f>qD?{Qv*} diff --git a/tests/ref/math-equation-font.png b/tests/ref/math-equation-font.png index eb84634e5a8f2c5e42a606d12505d505d63c3294..ec3c72311b4a40abad4b53074e7870c2042087db 100644 GIT binary patch delta 1023 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 N002ovPDHLkV1gW(A;bUx delta 834 zcmV-I1HJr<1=t3VBYy)QNklcHB<5?*i@o@c^IbymBXCA^i@1L`WLh=a zgU^jTb#dE4$bXZqC~OqQm3$Ew-hkXWuaCnxN=03KIU7<@`y!?cMnklSqpBe@HF;?- zVO-0hEiSIuro|u97R=L`pIQG*hV{y#uvIaNeZ9SP|Y{X{S2<~uHaTO2m z637y7d<>?bw0amLGBfQbmKmYOl2KwS2L4!7?~jkF2PnhD2f9Yi+RdowDKo;`>7%O*OBzw zDnwhnrIR@4Jm#Qgm)i;4vX#0xqJ}sJ{KQ`Uo4}>}sEg0s5nJ2LK`BGTGxVd_eQmgM zQ>lxq;&3T$H_D+)vP7I!fGfKuzZp8Iin@3@0DsaHgt%!Khu(>} Date: Tue, 1 Apr 2025 16:42:52 +0200 Subject: [PATCH 12/31] Switch PDF backend to `krilla` (#5420) Co-authored-by: Laurenz --- Cargo.lock | 200 ++++- Cargo.toml | 5 +- crates/typst-cli/src/args.rs | 32 +- crates/typst-cli/src/compile.rs | 39 +- crates/typst-layout/src/inline/shaping.rs | 40 +- crates/typst-library/src/layout/transform.rs | 14 + .../src/visualize/image/raster.rs | 27 +- crates/typst-pdf/Cargo.toml | 11 +- crates/typst-pdf/src/catalog.rs | 385 -------- crates/typst-pdf/src/color.rs | 394 --------- crates/typst-pdf/src/color_font.rs | 344 -------- crates/typst-pdf/src/content.rs | 823 ------------------ crates/typst-pdf/src/convert.rs | 661 ++++++++++++++ crates/typst-pdf/src/embed.rs | 150 +--- crates/typst-pdf/src/extg.rs | 53 -- crates/typst-pdf/src/font.rs | 278 ------ crates/typst-pdf/src/gradient.rs | 512 ----------- crates/typst-pdf/src/image.rs | 453 +++++----- crates/typst-pdf/src/lib.rs | 751 +++------------- crates/typst-pdf/src/link.rs | 94 ++ crates/typst-pdf/src/metadata.rs | 184 ++++ crates/typst-pdf/src/named_destination.rs | 86 -- crates/typst-pdf/src/outline.rs | 145 +-- crates/typst-pdf/src/page.rs | 348 ++------ crates/typst-pdf/src/paint.rs | 379 ++++++++ crates/typst-pdf/src/resources.rs | 349 -------- crates/typst-pdf/src/shape.rs | 106 +++ crates/typst-pdf/src/text.rs | 135 +++ crates/typst-pdf/src/tiling.rs | 184 ---- crates/typst-pdf/src/util.rs | 120 +++ 30 files changed, 2426 insertions(+), 4876 deletions(-) delete mode 100644 crates/typst-pdf/src/catalog.rs delete mode 100644 crates/typst-pdf/src/color.rs delete mode 100644 crates/typst-pdf/src/color_font.rs delete mode 100644 crates/typst-pdf/src/content.rs create mode 100644 crates/typst-pdf/src/convert.rs delete mode 100644 crates/typst-pdf/src/extg.rs delete mode 100644 crates/typst-pdf/src/font.rs delete mode 100644 crates/typst-pdf/src/gradient.rs create mode 100644 crates/typst-pdf/src/link.rs create mode 100644 crates/typst-pdf/src/metadata.rs delete mode 100644 crates/typst-pdf/src/named_destination.rs create mode 100644 crates/typst-pdf/src/paint.rs delete mode 100644 crates/typst-pdf/src/resources.rs create mode 100644 crates/typst-pdf/src/shape.rs create mode 100644 crates/typst-pdf/src/text.rs delete mode 100644 crates/typst-pdf/src/tiling.rs create mode 100644 crates/typst-pdf/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 630eade2f..c13c64819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,20 @@ name = "bytemuck" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder" @@ -735,11 +749,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -749,6 +764,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -761,6 +785,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "font-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d868ec188a98bb014c606072edd47e52e7ab7297db943b0b28503121e1d037bd" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -829,6 +862,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getopts" version = "0.2.21" @@ -966,7 +1008,7 @@ checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1064,7 +1106,7 @@ dependencies = [ "stable_deref_trait", "tinystr", "writeable", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1310,6 +1352,48 @@ dependencies = [ "libc", ] +[[package]] +name = "krilla" +version = "0.3.0" +source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +dependencies = [ + "base64", + "bumpalo", + "comemo", + "flate2", + "float-cmp 0.10.0", + "fxhash", + "gif", + "image-webp", + "imagesize", + "once_cell", + "pdf-writer", + "png", + "rayon", + "rustybuzz", + "siphasher", + "skrifa", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke 0.8.0", + "zune-jpeg", +] + +[[package]] +name = "krilla-svg" +version = "0.3.0" +source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "png", + "resvg", + "tiny-skia", + "usvg", +] + [[package]] name = "kurbo" version = "0.11.1" @@ -1371,6 +1455,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902bc563b5d65ad9bba616b490842ef0651066a1a1dc3ce1087113ffcb873c8d" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1458,9 +1551,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", @@ -1739,8 +1832,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df03c7d216de06f93f398ef06f1385a60f2c597bb96f8195c8d98e08a26b1d5" +source = "git+https://github.com/typst/pdf-writer?rev=0d513b9#0d513b9050d2f1a0507cabb4898aca971af6da98" dependencies = [ "bitflags 2.8.0", "itoa", @@ -1997,6 +2089,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f004ee5c610b8beb5f33273246893ac6258ec22185a6eb8ee132bccdb904cdaa" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -2315,6 +2417,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e7936ca3627fdb516e97aca3c8ab5103f94ae32fe5ce80a0a02edcbacb7b53" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slotmap" version = "1.0.7" @@ -2361,7 +2473,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "float-cmp", + "float-cmp 0.9.0", ] [[package]] @@ -2405,27 +2517,9 @@ dependencies = [ [[package]] name = "subsetter" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f98178f34057d4d4de93d68104007c6dea4dfac930204a69ab4622daefa648" - -[[package]] -name = "svg2pdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22" +source = "git+https://github.com/typst/subsetter?rev=460fdb6#460fdb66d6e0138b721b1ca9882faf15ce003246" dependencies = [ - "fontdb", - "image", - "log", - "miniz_oxide", - "once_cell", - "pdf-writer", - "resvg", - "siphasher", - "subsetter", - "tiny-skia", - "ttf-parser", - "usvg", + "fxhash", ] [[package]] @@ -3018,26 +3112,19 @@ dependencies = [ name = "typst-pdf" version = "0.13.1" dependencies = [ - "arrayvec", - "base64", "bytemuck", "comemo", "ecow", "image", - "indexmap 2.7.1", - "miniz_oxide", - "pdf-writer", + "krilla", + "krilla-svg", "serde", - "subsetter", - "svg2pdf", - "ttf-parser", "typst-assets", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", - "xmp-writer", ] [[package]] @@ -3662,8 +3749,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xmp-writer" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb5954c9ca6dcc869e98d3e42760ed9dab08f3e70212b31d7ab8ae7f3b7a487" +source = "git+https://github.com/LaurenzV/xmp-writer?rev=a1cbb887#a1cbb887a84376fea4d7590d41c194a332840549" [[package]] name = "xz2" @@ -3701,7 +3787,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -3717,6 +3815,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3778,7 +3888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec-derive", ] @@ -3809,6 +3919,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20717f0917c908dc63de2e44e97f1e6b126ca58d0e391cee86d504eb8fbd05" + [[package]] name = "zopfli" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index a73241832..cbe69a05d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,11 +71,12 @@ if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.6" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7" } kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" memchr = "2" -miniz_oxide = "0.8" native-tls = "0.2" notify = "8" once_cell = "1" @@ -113,7 +114,6 @@ siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" subsetter = "0.2" -svg2pdf = "0.13" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" @@ -140,7 +140,6 @@ wasmi = "0.40.0" web-sys = "0.3" xmlparser = "0.13.5" xmlwriter = "0.1.0" -xmp-writer = "0.3.1" xz2 = { version = "0.1", features = ["static"] } yaml-front-matter = "0.1" zip = { version = "2.5", default-features = false, features = ["deflate"] } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 76f647276..fd0eb5f05 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -467,15 +467,45 @@ display_possible_values!(Feature); #[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] #[allow(non_camel_case_types)] pub enum PdfStandard { + /// PDF 1.4. + #[value(name = "1.4")] + V_1_4, + /// PDF 1.5. + #[value(name = "1.5")] + V_1_5, + /// PDF 1.5. + #[value(name = "1.6")] + V_1_6, /// PDF 1.7. #[value(name = "1.7")] V_1_7, + /// PDF 2.0. + #[value(name = "2.0")] + V_2_0, + /// PDF/A-1b. + #[value(name = "a-1b")] + A_1b, /// PDF/A-2b. #[value(name = "a-2b")] A_2b, - /// PDF/A-3b. + /// PDF/A-2u. + #[value(name = "a-2u")] + A_2u, + /// PDF/A-3u. #[value(name = "a-3b")] A_3b, + /// PDF/A-3u. + #[value(name = "a-3u")] + A_3u, + /// PDF/A-4. + #[value(name = "a-4")] + A_4, + /// PDF/A-4f. + #[value(name = "a-4f")] + A_4f, + /// PDF/A-4e. + #[value(name = "a-4e")] + A_4e, } display_possible_values!(PdfStandard); diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index ae71e298c..4edb4c323 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -63,8 +63,7 @@ pub struct CompileConfig { /// Opens the output file with the default viewer or a specific program after /// compilation. pub open: Option>, - /// One (or multiple comma-separated) PDF standards that Typst will enforce - /// conformance with. + /// A list of standards the PDF should conform to. pub pdf_standards: PdfStandards, /// A path to write a Makefile rule describing the current compilation. pub make_deps: Option, @@ -130,18 +129,9 @@ impl CompileConfig { PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) }); - let pdf_standards = { - let list = args - .pdf_standard - .iter() - .map(|standard| match standard { - PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, - PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, - PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, - }) - .collect::>(); - PdfStandards::new(&list)? - }; + let pdf_standards = PdfStandards::new( + &args.pdf_standard.iter().copied().map(Into::into).collect::>(), + )?; #[cfg(feature = "http-server")] let server = match watch { @@ -295,6 +285,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< }) } }; + let options = PdfOptions { ident: Smart::Auto, timestamp, @@ -765,3 +756,23 @@ impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { }) } } + +impl From for typst_pdf::PdfStandard { + fn from(standard: PdfStandard) -> Self { + match standard { + PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, + PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, + PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, + PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, + PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, + PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, + PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, + PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, + PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, + PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, + PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + } + } +} diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 159619eb3..8236d1e36 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -824,12 +824,42 @@ fn shape_segment<'a>( // Add the glyph to the shaped output. if info.glyph_id != 0 && is_covered(cluster) { - // Determine the text range of the glyph. + // Assume we have the following sequence of (glyph_id, cluster): + // [(120, 0), (80, 0), (3, 3), (755, 4), (69, 4), (424, 13), + // (63, 13), (193, 25), (80, 25), (3, 31) + // + // We then want the sequence of (glyph_id, text_range) to look as follows: + // [(120, 0..3), (80, 0..3), (3, 3..4), (755, 4..13), (69, 4..13), + // (424, 13..25), (63, 13..25), (193, 25..31), (80, 25..31), (3, 31..x)] + // + // Each glyph in the same cluster should be assigned the full text + // range. This is necessary because only this way krilla can + // properly assign `ActualText` attributes in complex shaping + // scenarios. + + // The start of the glyph's text range. let start = base + cluster; - let end = base - + if ltr { i.checked_add(1) } else { i.checked_sub(1) } - .and_then(|last| infos.get(last)) - .map_or(text.len(), |info| info.cluster as usize); + + // Determine the end of the glyph's text range. + let mut k = i; + let step: isize = if ltr { 1 } else { -1 }; + let end = loop { + // If we've reached the end of the glyphs, the `end` of the + // range should be the end of the full text. + let Some((next, next_info)) = k + .checked_add_signed(step) + .and_then(|n| infos.get(n).map(|info| (n, info))) + else { + break base + text.len(); + }; + + // If the cluster doesn't match anymore, we've reached the end. + if next_info.cluster != info.cluster { + break base + next_info.cluster as usize; + } + + k = next; + }; let c = text[cluster..].chars().next().unwrap(); let script = c.script(); diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs index 183df6098..d153d97db 100644 --- a/crates/typst-library/src/layout/transform.rs +++ b/crates/typst-library/src/layout/transform.rs @@ -307,6 +307,20 @@ impl Transform { Self { sx, sy, ..Self::identity() } } + /// A scale transform at a specific position. + pub fn scale_at(sx: Ratio, sy: Ratio, px: Abs, py: Abs) -> Self { + Self::translate(px, py) + .pre_concat(Self::scale(sx, sy)) + .pre_concat(Self::translate(-px, -py)) + } + + /// A rotate transform at a specific position. + pub fn rotate_at(angle: Angle, px: Abs, py: Abs) -> Self { + Self::translate(px, py) + .pre_concat(Self::rotate(angle)) + .pre_concat(Self::translate(-px, -py)) + } + /// A rotate transform. pub fn rotate(angle: Angle) -> Self { let cos = Ratio::new(angle.cos()); diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 453b94066..21d5b18fc 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -3,6 +3,8 @@ use std::hash::{Hash, Hasher}; use std::io; use std::sync::Arc; +use crate::diag::{bail, StrResult}; +use crate::foundations::{cast, dict, Bytes, Cast, Dict, Smart, Value}; use ecow::{eco_format, EcoString}; use image::codecs::gif::GifDecoder; use image::codecs::jpeg::JpegDecoder; @@ -11,9 +13,6 @@ use image::{ guess_format, DynamicImage, ImageBuffer, ImageDecoder, ImageResult, Limits, Pixel, }; -use crate::diag::{bail, StrResult}; -use crate::foundations::{cast, dict, Bytes, Cast, Dict, Smart, Value}; - /// A decoded raster image. #[derive(Clone, Hash)] pub struct RasterImage(Arc); @@ -22,7 +21,8 @@ pub struct RasterImage(Arc); struct Repr { data: Bytes, format: RasterFormat, - dynamic: image::DynamicImage, + dynamic: Arc, + exif_rotation: Option, icc: Option, dpi: Option, } @@ -50,6 +50,8 @@ impl RasterImage { format: RasterFormat, icc: Smart, ) -> StrResult { + let mut exif_rot = None; + let (dynamic, icc, dpi) = match format { RasterFormat::Exchange(format) => { fn decode( @@ -85,6 +87,7 @@ impl RasterImage { // Apply rotation from EXIF metadata. if let Some(rotation) = exif.as_ref().and_then(exif_rotation) { apply_rotation(&mut dynamic, rotation); + exif_rot = Some(rotation); } // Extract pixel density. @@ -136,7 +139,14 @@ impl RasterImage { } }; - Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) + Ok(Self(Arc::new(Repr { + data, + format, + exif_rotation: exif_rot, + dynamic: Arc::new(dynamic), + icc, + dpi, + }))) } /// The raw image data. @@ -159,6 +169,11 @@ impl RasterImage { self.dynamic().height() } + /// TODO. + pub fn exif_rotation(&self) -> Option { + self.0.exif_rotation + } + /// The image's pixel density in pixels per inch, if known. /// /// This is guaranteed to be positive. @@ -167,7 +182,7 @@ impl RasterImage { } /// Access the underlying dynamic image. - pub fn dynamic(&self) -> &image::DynamicImage { + pub fn dynamic(&self) -> &Arc { &self.0.dynamic } diff --git a/crates/typst-pdf/Cargo.toml b/crates/typst-pdf/Cargo.toml index bc0da06c3..f6f08b5bc 100644 --- a/crates/typst-pdf/Cargo.toml +++ b/crates/typst-pdf/Cargo.toml @@ -19,20 +19,13 @@ typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } -arrayvec = { workspace = true } -base64 = { workspace = true } bytemuck = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } image = { workspace = true } -indexmap = { workspace = true } -miniz_oxide = { workspace = true } -pdf-writer = { workspace = true } +krilla = { workspace = true } +krilla-svg = { workspace = true } serde = { workspace = true } -subsetter = { workspace = true } -svg2pdf = { workspace = true } -ttf-parser = { workspace = true } -xmp-writer = { workspace = true } [lints] workspace = true diff --git a/crates/typst-pdf/src/catalog.rs b/crates/typst-pdf/src/catalog.rs deleted file mode 100644 index 709b01553..000000000 --- a/crates/typst-pdf/src/catalog.rs +++ /dev/null @@ -1,385 +0,0 @@ -use std::num::NonZeroUsize; - -use ecow::eco_format; -use pdf_writer::types::Direction; -use pdf_writer::writers::PageLabel; -use pdf_writer::{Finish, Name, Pdf, Ref, Str, TextStr}; -use typst_library::diag::{bail, SourceResult}; -use typst_library::foundations::{Datetime, Smart}; -use typst_library::layout::Dir; -use typst_library::text::Lang; -use typst_syntax::Span; -use xmp_writer::{DateTime, LangId, RenditionClass, XmpWriter}; - -use crate::page::PdfPageLabel; -use crate::{hash_base64, outline, TextStrExt, Timestamp, Timezone, WithEverything}; - -/// Write the document catalog. -pub fn write_catalog( - ctx: WithEverything, - pdf: &mut Pdf, - alloc: &mut Ref, -) -> SourceResult<()> { - let lang = ctx - .resources - .languages - .iter() - .max_by_key(|(_, &count)| count) - .map(|(&l, _)| l); - - let dir = if lang.map(Lang::dir) == Some(Dir::RTL) { - Direction::R2L - } else { - Direction::L2R - }; - - // Write the outline tree. - let outline_root_id = outline::write_outline(pdf, alloc, &ctx); - - // Write the page labels. - let page_labels = write_page_labels(pdf, alloc, &ctx); - - // Write the document information. - let info_ref = alloc.bump(); - let mut info = pdf.document_info(info_ref); - let mut xmp = XmpWriter::new(); - if let Some(title) = &ctx.document.info.title { - info.title(TextStr::trimmed(title)); - xmp.title([(None, title.as_str())]); - } - - if let Some(description) = &ctx.document.info.description { - info.subject(TextStr::trimmed(description)); - xmp.description([(None, description.as_str())]); - } - - let authors = &ctx.document.info.author; - if !authors.is_empty() { - // Turns out that if the authors are given in both the document - // information dictionary and the XMP metadata, Acrobat takes a little - // bit of both: The first author from the document information - // dictionary and the remaining authors from the XMP metadata. - // - // To fix this for Acrobat, we could omit the remaining authors or all - // metadata from the document information catalog (it is optional) and - // only write XMP. However, not all other tools (including Apple - // Preview) read the XMP data. This means we do want to include all - // authors in the document information dictionary. - // - // Thus, the only alternative is to fold all authors into a single - // `` in the XMP metadata. This is, in fact, exactly what the - // PDF/A spec Part 1 section 6.7.3 has to say about the matter. It's a - // bit weird to not use the array (and it makes Acrobat show the author - // list in quotes), but there's not much we can do about that. - let joined = authors.join(", "); - info.author(TextStr::trimmed(&joined)); - xmp.creator([joined.as_str()]); - } - - let creator = eco_format!("Typst {}", env!("CARGO_PKG_VERSION")); - info.creator(TextStr(&creator)); - xmp.creator_tool(&creator); - - let keywords = &ctx.document.info.keywords; - if !keywords.is_empty() { - let joined = keywords.join(", "); - info.keywords(TextStr::trimmed(&joined)); - xmp.pdf_keywords(&joined); - } - let (date, tz) = document_date(ctx.document.info.date, ctx.options.timestamp); - if let Some(pdf_date) = date.and_then(|date| pdf_date(date, tz)) { - info.creation_date(pdf_date); - info.modified_date(pdf_date); - } - - info.finish(); - - // A unique ID for this instance of the document. Changes if anything - // changes in the frames. - let instance_id = hash_base64(&pdf.as_bytes()); - - // Determine the document's ID. It should be as stable as possible. - const PDF_VERSION: &str = "PDF-1.7"; - let doc_id = if let Smart::Custom(ident) = ctx.options.ident { - // We were provided with a stable ID. Yay! - hash_base64(&(PDF_VERSION, ident)) - } else if ctx.document.info.title.is_some() && !ctx.document.info.author.is_empty() { - // If not provided from the outside, but title and author were given, we - // compute a hash of them, which should be reasonably stable and unique. - hash_base64(&(PDF_VERSION, &ctx.document.info.title, &ctx.document.info.author)) - } else { - // The user provided no usable metadata which we can use as an `/ID`. - instance_id.clone() - }; - - xmp.document_id(&doc_id); - xmp.instance_id(&instance_id); - xmp.format("application/pdf"); - xmp.pdf_version("1.7"); - xmp.language(ctx.resources.languages.keys().map(|lang| LangId(lang.as_str()))); - xmp.num_pages(ctx.document.pages.len() as u32); - xmp.rendition_class(RenditionClass::Proof); - - if let Some(xmp_date) = date.and_then(|date| xmp_date(date, tz)) { - xmp.create_date(xmp_date); - xmp.modify_date(xmp_date); - - if ctx.options.standards.pdfa { - let mut history = xmp.history(); - history - .add_event() - .action(xmp_writer::ResourceEventAction::Saved) - .when(xmp_date) - .instance_id(&eco_format!("{instance_id}_source")); - history - .add_event() - .action(xmp_writer::ResourceEventAction::Converted) - .when(xmp_date) - .instance_id(&instance_id) - .software_agent(&creator); - } - } - - // Assert dominance. - if let Some((part, conformance)) = ctx.options.standards.pdfa_part { - let mut extension_schemas = xmp.extension_schemas(); - extension_schemas - .xmp_media_management() - .properties() - .describe_instance_id(); - extension_schemas.pdf().properties().describe_all(); - extension_schemas.finish(); - xmp.pdfa_part(part); - xmp.pdfa_conformance(conformance); - } - - let xmp_buf = xmp.finish(None); - let meta_ref = alloc.bump(); - pdf.stream(meta_ref, xmp_buf.as_bytes()) - .pair(Name(b"Type"), Name(b"Metadata")) - .pair(Name(b"Subtype"), Name(b"XML")); - - // Set IDs only now, so that we don't need to clone them. - pdf.set_file_id((doc_id.into_bytes(), instance_id.into_bytes())); - - // Write the document catalog. - let catalog_ref = alloc.bump(); - let mut catalog = pdf.catalog(catalog_ref); - catalog.pages(ctx.page_tree_ref); - catalog.viewer_preferences().direction(dir); - catalog.metadata(meta_ref); - - let has_dests = !ctx.references.named_destinations.dests.is_empty(); - let has_embeddings = !ctx.references.embedded_files.is_empty(); - - // Write the `/Names` dictionary. - if has_dests || has_embeddings { - // Write the named destination tree if there are any entries. - let mut name_dict = catalog.names(); - if has_dests { - let mut dests_name_tree = name_dict.destinations(); - let mut names = dests_name_tree.names(); - for &(name, dest_ref, ..) in &ctx.references.named_destinations.dests { - names.insert(Str(name.resolve().as_bytes()), dest_ref); - } - } - - if has_embeddings { - let mut embedded_files = name_dict.embedded_files(); - let mut names = embedded_files.names(); - for (name, file_ref) in &ctx.references.embedded_files { - names.insert(Str(name.as_bytes()), *file_ref); - } - } - } - - if has_embeddings && ctx.options.standards.pdfa { - // PDF 2.0, but ISO 19005-3 (PDF/A-3) Annex E allows it for PDF/A-3. - let mut associated_files = catalog.insert(Name(b"AF")).array().typed(); - for (_, file_ref) in ctx.references.embedded_files { - associated_files.item(file_ref).finish(); - } - } - - // Insert the page labels. - if !page_labels.is_empty() { - let mut num_tree = catalog.page_labels(); - let mut entries = num_tree.nums(); - for (n, r) in &page_labels { - entries.insert(n.get() as i32 - 1, *r); - } - } - - if let Some(outline_root_id) = outline_root_id { - catalog.outlines(outline_root_id); - } - - if let Some(lang) = lang { - catalog.lang(TextStr(lang.as_str())); - } - - if ctx.options.standards.pdfa { - catalog - .output_intents() - .push() - .subtype(pdf_writer::types::OutputIntentSubtype::PDFA) - .output_condition(TextStr("sRGB")) - .output_condition_identifier(TextStr("Custom")) - .info(TextStr("sRGB IEC61966-2.1")) - .dest_output_profile(ctx.globals.color_functions.srgb.unwrap()); - } - - catalog.finish(); - - if ctx.options.standards.pdfa && pdf.refs().count() > 8388607 { - bail!(Span::detached(), "too many PDF objects"); - } - - Ok(()) -} - -/// Write the page labels. -pub(crate) fn write_page_labels( - chunk: &mut Pdf, - alloc: &mut Ref, - ctx: &WithEverything, -) -> Vec<(NonZeroUsize, Ref)> { - // If there is no exported page labeled, we skip the writing - if !ctx.pages.iter().filter_map(Option::as_ref).any(|p| { - p.label - .as_ref() - .is_some_and(|l| l.prefix.is_some() || l.style.is_some()) - }) { - return Vec::new(); - } - - let empty_label = PdfPageLabel::default(); - let mut result = vec![]; - let mut prev: Option<&PdfPageLabel> = None; - - // Skip non-exported pages for numbering. - for (i, page) in ctx.pages.iter().filter_map(Option::as_ref).enumerate() { - let nr = NonZeroUsize::new(1 + i).unwrap(); - // If there are pages with empty labels between labeled pages, we must - // write empty PageLabel entries. - let label = page.label.as_ref().unwrap_or(&empty_label); - - if let Some(pre) = prev { - if label.prefix == pre.prefix - && label.style == pre.style - && label.offset == pre.offset.map(|n| n.saturating_add(1)) - { - prev = Some(label); - continue; - } - } - - let id = alloc.bump(); - let mut entry = chunk.indirect(id).start::(); - - // Only add what is actually provided. Don't add empty prefix string if - // it wasn't given for example. - if let Some(prefix) = &label.prefix { - entry.prefix(TextStr::trimmed(prefix)); - } - - if let Some(style) = label.style { - entry.style(style.to_pdf_numbering_style()); - } - - if let Some(offset) = label.offset { - entry.offset(offset.get() as i32); - } - - result.push((nr, id)); - prev = Some(label); - } - - result -} - -/// Resolve the document date. -/// -/// (1) If the `document.date` is set to specific `datetime` or `none`, use it. -/// (2) If the `document.date` is set to `auto` or not set, try to use the -/// date from the options. -/// (3) Otherwise, we don't write date metadata. -pub fn document_date( - document_date: Smart>, - timestamp: Option, -) -> (Option, Option) { - match (document_date, timestamp) { - (Smart::Custom(date), _) => (date, None), - (Smart::Auto, Some(timestamp)) => { - (Some(timestamp.datetime), Some(timestamp.timezone)) - } - _ => (None, None), - } -} - -/// Converts a datetime to a pdf-writer date. -pub fn pdf_date(datetime: Datetime, tz: Option) -> Option { - let year = datetime.year().filter(|&y| y >= 0)? as u16; - - let mut pdf_date = pdf_writer::Date::new(year); - - if let Some(month) = datetime.month() { - pdf_date = pdf_date.month(month); - } - - if let Some(day) = datetime.day() { - pdf_date = pdf_date.day(day); - } - - if let Some(h) = datetime.hour() { - pdf_date = pdf_date.hour(h); - } - - if let Some(m) = datetime.minute() { - pdf_date = pdf_date.minute(m); - } - - if let Some(s) = datetime.second() { - pdf_date = pdf_date.second(s); - } - - match tz { - Some(Timezone::UTC) => { - pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0) - } - Some(Timezone::Local { hour_offset, minute_offset }) => { - pdf_date = - pdf_date.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset) - } - None => {} - } - - Some(pdf_date) -} - -/// Converts a datetime to an xmp-writer datetime. -fn xmp_date( - datetime: Datetime, - timezone: Option, -) -> Option { - let year = datetime.year().filter(|&y| y >= 0)? as u16; - let timezone = timezone.map(|tz| match tz { - Timezone::UTC => xmp_writer::Timezone::Utc, - Timezone::Local { hour_offset, minute_offset } => { - // The xmp-writer use signed integers for the minute offset, which - // can be buggy if the minute offset is negative. And because our - // minute_offset is ensured to be `0 <= minute_offset < 60`, we can - // safely cast it to a signed integer. - xmp_writer::Timezone::Local { hour: hour_offset, minute: minute_offset as i8 } - } - }); - Some(DateTime { - year, - month: datetime.month(), - day: datetime.day(), - hour: datetime.hour(), - minute: datetime.minute(), - second: datetime.second(), - timezone, - }) -} diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs deleted file mode 100644 index 412afca9a..000000000 --- a/crates/typst-pdf/src/color.rs +++ /dev/null @@ -1,394 +0,0 @@ -use std::sync::LazyLock; - -use arrayvec::ArrayVec; -use pdf_writer::{writers, Chunk, Dict, Filter, Name, Ref}; -use typst_library::diag::{bail, SourceResult}; -use typst_library::visualize::{Color, ColorSpace, Paint}; -use typst_syntax::Span; - -use crate::{content, deflate, PdfChunk, PdfOptions, Renumber, WithResources}; - -// The names of the color spaces. -pub const SRGB: Name<'static> = Name(b"srgb"); -pub const D65_GRAY: Name<'static> = Name(b"d65gray"); -pub const LINEAR_SRGB: Name<'static> = Name(b"linearrgb"); - -// The ICC profiles. -static SRGB_ICC_DEFLATED: LazyLock> = - LazyLock::new(|| deflate(typst_assets::icc::S_RGB_V4)); -static GRAY_ICC_DEFLATED: LazyLock> = - LazyLock::new(|| deflate(typst_assets::icc::S_GREY_V4)); - -/// The color spaces present in the PDF document -#[derive(Default)] -pub struct ColorSpaces { - use_srgb: bool, - use_d65_gray: bool, - use_linear_rgb: bool, -} - -impl ColorSpaces { - /// Mark a color space as used. - pub fn mark_as_used(&mut self, color_space: ColorSpace) { - match color_space { - ColorSpace::Oklch - | ColorSpace::Oklab - | ColorSpace::Hsl - | ColorSpace::Hsv - | ColorSpace::Srgb => { - self.use_srgb = true; - } - ColorSpace::D65Gray => { - self.use_d65_gray = true; - } - ColorSpace::LinearRgb => { - self.use_linear_rgb = true; - } - ColorSpace::Cmyk => {} - } - } - - /// Write the color spaces to the PDF file. - pub fn write_color_spaces(&self, mut spaces: Dict, refs: &ColorFunctionRefs) { - if self.use_srgb { - write(ColorSpace::Srgb, spaces.insert(SRGB).start(), refs); - } - - if self.use_d65_gray { - write(ColorSpace::D65Gray, spaces.insert(D65_GRAY).start(), refs); - } - - if self.use_linear_rgb { - write(ColorSpace::LinearRgb, spaces.insert(LINEAR_SRGB).start(), refs); - } - } - - /// Write the necessary color spaces functions and ICC profiles to the - /// PDF file. - pub fn write_functions(&self, chunk: &mut Chunk, refs: &ColorFunctionRefs) { - // Write the sRGB color space. - if let Some(id) = refs.srgb { - chunk - .icc_profile(id, &SRGB_ICC_DEFLATED) - .n(3) - .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) - .filter(Filter::FlateDecode); - } - - // Write the gray color space. - if let Some(id) = refs.d65_gray { - chunk - .icc_profile(id, &GRAY_ICC_DEFLATED) - .n(1) - .range([0.0, 1.0]) - .filter(Filter::FlateDecode); - } - } - - /// Merge two color space usage information together: a given color space is - /// considered to be used if it is used on either side. - pub fn merge(&mut self, other: &Self) { - self.use_d65_gray |= other.use_d65_gray; - self.use_linear_rgb |= other.use_linear_rgb; - self.use_srgb |= other.use_srgb; - } -} - -/// Write the color space. -pub fn write( - color_space: ColorSpace, - writer: writers::ColorSpace, - refs: &ColorFunctionRefs, -) { - match color_space { - ColorSpace::Srgb - | ColorSpace::Oklab - | ColorSpace::Hsl - | ColorSpace::Hsv - | ColorSpace::Oklch => writer.icc_based(refs.srgb.unwrap()), - ColorSpace::D65Gray => writer.icc_based(refs.d65_gray.unwrap()), - ColorSpace::LinearRgb => { - writer.cal_rgb( - [0.9505, 1.0, 1.0888], - None, - Some([1.0, 1.0, 1.0]), - Some([ - 0.4124, 0.2126, 0.0193, 0.3576, 0.715, 0.1192, 0.1805, 0.0722, 0.9505, - ]), - ); - } - ColorSpace::Cmyk => writer.device_cmyk(), - } -} - -/// Global references for color conversion functions. -/// -/// These functions are only written once (at most, they are not written if not -/// needed) in the final document, and be shared by all color space -/// dictionaries. -pub struct ColorFunctionRefs { - pub srgb: Option, - d65_gray: Option, -} - -impl Renumber for ColorFunctionRefs { - fn renumber(&mut self, offset: i32) { - if let Some(r) = &mut self.srgb { - r.renumber(offset); - } - if let Some(r) = &mut self.d65_gray { - r.renumber(offset); - } - } -} - -/// Allocate all necessary [`ColorFunctionRefs`]. -pub fn alloc_color_functions_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, ColorFunctionRefs)> { - let mut chunk = PdfChunk::new(); - let mut used_color_spaces = ColorSpaces::default(); - - if context.options.standards.pdfa { - used_color_spaces.mark_as_used(ColorSpace::Srgb); - } - - context.resources.traverse(&mut |r| { - used_color_spaces.merge(&r.colors); - Ok(()) - })?; - - let refs = ColorFunctionRefs { - srgb: if used_color_spaces.use_srgb { Some(chunk.alloc()) } else { None }, - d65_gray: if used_color_spaces.use_d65_gray { Some(chunk.alloc()) } else { None }, - }; - - Ok((chunk, refs)) -} - -/// Encodes the color into four f32s, which can be used in a PDF file. -/// Ensures that the values are in the range [0.0, 1.0]. -/// -/// # Why? -/// - Oklab: The a and b components are in the range [-0.5, 0.5] and the PDF -/// specifies (and some readers enforce) that all color values be in the range -/// [0.0, 1.0]. This means that the PostScript function and the encoded color -/// must be offset by 0.5. -/// - HSV/HSL: The hue component is in the range [0.0, 360.0] and the PDF format -/// specifies that it must be in the range [0.0, 1.0]. This means that the -/// PostScript function and the encoded color must be divided by 360.0. -pub trait ColorEncode { - /// Performs the color to PDF f32 array conversion. - fn encode(&self, color: Color) -> [f32; 4]; -} - -impl ColorEncode for ColorSpace { - fn encode(&self, color: Color) -> [f32; 4] { - match self { - ColorSpace::Oklab | ColorSpace::Oklch | ColorSpace::Hsl | ColorSpace::Hsv => { - color.to_space(ColorSpace::Srgb).to_vec4() - } - _ => color.to_space(*self).to_vec4(), - } - } -} - -/// Encodes a paint into either a fill or stroke color. -pub(super) trait PaintEncode { - /// Set the paint as the fill color. - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()>; - - /// Set the paint as the stroke color. - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()>; -} - -impl PaintEncode for Paint { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - match self { - Self::Solid(c) => c.set_as_fill(ctx, on_text, transforms), - Self::Gradient(gradient) => gradient.set_as_fill(ctx, on_text, transforms), - Self::Tiling(tiling) => tiling.set_as_fill(ctx, on_text, transforms), - } - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - match self { - Self::Solid(c) => c.set_as_stroke(ctx, on_text, transforms), - Self::Gradient(gradient) => gradient.set_as_stroke(ctx, on_text, transforms), - Self::Tiling(tiling) => tiling.set_as_stroke(ctx, on_text, transforms), - } - } -} - -impl PaintEncode for Color { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - _: bool, - _: content::Transforms, - ) -> SourceResult<()> { - match self { - Color::Luma(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::D65Gray); - ctx.set_fill_color_space(D65_GRAY); - - let [l, _, _, _] = ColorSpace::D65Gray.encode(*self); - ctx.content.set_fill_color([l]); - } - Color::LinearRgb(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::LinearRgb); - ctx.set_fill_color_space(LINEAR_SRGB); - - let [r, g, b, _] = ColorSpace::LinearRgb.encode(*self); - ctx.content.set_fill_color([r, g, b]); - } - // Oklab & friends are encoded as RGB. - Color::Rgb(_) - | Color::Oklab(_) - | Color::Oklch(_) - | Color::Hsl(_) - | Color::Hsv(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::Srgb); - ctx.set_fill_color_space(SRGB); - - let [r, g, b, _] = ColorSpace::Srgb.encode(*self); - ctx.content.set_fill_color([r, g, b]); - } - Color::Cmyk(_) => { - check_cmyk_allowed(ctx.options)?; - ctx.reset_fill_color_space(); - - let [c, m, y, k] = ColorSpace::Cmyk.encode(*self); - ctx.content.set_fill_cmyk(c, m, y, k); - } - } - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - _: bool, - _: content::Transforms, - ) -> SourceResult<()> { - match self { - Color::Luma(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::D65Gray); - ctx.set_stroke_color_space(D65_GRAY); - - let [l, _, _, _] = ColorSpace::D65Gray.encode(*self); - ctx.content.set_stroke_color([l]); - } - Color::LinearRgb(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::LinearRgb); - ctx.set_stroke_color_space(LINEAR_SRGB); - - let [r, g, b, _] = ColorSpace::LinearRgb.encode(*self); - ctx.content.set_stroke_color([r, g, b]); - } - // Oklab & friends are encoded as RGB. - Color::Rgb(_) - | Color::Oklab(_) - | Color::Oklch(_) - | Color::Hsl(_) - | Color::Hsv(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::Srgb); - ctx.set_stroke_color_space(SRGB); - - let [r, g, b, _] = ColorSpace::Srgb.encode(*self); - ctx.content.set_stroke_color([r, g, b]); - } - Color::Cmyk(_) => { - check_cmyk_allowed(ctx.options)?; - ctx.reset_stroke_color_space(); - - let [c, m, y, k] = ColorSpace::Cmyk.encode(*self); - ctx.content.set_stroke_cmyk(c, m, y, k); - } - } - Ok(()) - } -} - -/// Extra color space functions. -pub(super) trait ColorSpaceExt { - /// Returns the range of the color space. - fn range(self) -> &'static [f32]; - - /// Converts a color to the color space. - fn convert(self, color: Color) -> ArrayVec; -} - -impl ColorSpaceExt for ColorSpace { - fn range(self) -> &'static [f32] { - match self { - ColorSpace::D65Gray => &[0.0, 1.0], - ColorSpace::Oklab => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Oklch => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::LinearRgb => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Srgb => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Cmyk => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Hsl => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Hsv => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - } - } - - fn convert(self, color: Color) -> ArrayVec { - let components = self.encode(color); - - self.range() - .chunks(2) - .zip(components) - .map(|(range, component)| U::quantize(component, [range[0], range[1]])) - .collect() - } -} - -/// Quantizes a color component to a specific type. -pub(super) trait QuantizedColor { - fn quantize(color: f32, range: [f32; 2]) -> Self; -} - -impl QuantizedColor for u16 { - fn quantize(color: f32, [min, max]: [f32; 2]) -> Self { - let value = (color - min) / (max - min); - (value * Self::MAX as f32).round().clamp(0.0, Self::MAX as f32) as Self - } -} - -impl QuantizedColor for f32 { - fn quantize(color: f32, [min, max]: [f32; 2]) -> Self { - color.clamp(min, max) - } -} - -/// Fails with an error if PDF/A processing is enabled. -pub(super) fn check_cmyk_allowed(options: &PdfOptions) -> SourceResult<()> { - if options.standards.pdfa { - bail!( - Span::detached(), - "cmyk colors are not currently supported by PDF/A export" - ); - } - Ok(()) -} diff --git a/crates/typst-pdf/src/color_font.rs b/crates/typst-pdf/src/color_font.rs deleted file mode 100644 index 1183e966e..000000000 --- a/crates/typst-pdf/src/color_font.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! OpenType fonts generally define monochrome glyphs, but they can also define -//! glyphs with colors. This is how emojis are generally implemented for -//! example. -//! -//! There are various standards to represent color glyphs, but PDF readers don't -//! support any of them natively, so Typst has to handle them manually. - -use std::collections::HashMap; - -use ecow::eco_format; -use indexmap::IndexMap; -use pdf_writer::types::UnicodeCmap; -use pdf_writer::writers::WMode; -use pdf_writer::{Filter, Finish, Name, Rect, Ref}; -use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; -use typst_library::foundations::Repr; -use typst_library::layout::Em; -use typst_library::text::color::glyph_frame; -use typst_library::text::{Font, Glyph, TextItemView}; - -use crate::font::{base_font_name, write_font_descriptor, CMAP_NAME, SYSTEM_INFO}; -use crate::resources::{Resources, ResourcesRefs}; -use crate::{content, EmExt, PdfChunk, PdfOptions, WithGlobalRefs}; - -/// Write color fonts in the PDF document. -/// -/// They are written as Type3 fonts, which map glyph IDs to arbitrary PDF -/// instructions. -pub fn write_color_fonts( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut out = HashMap::new(); - let mut chunk = PdfChunk::new(); - context.resources.traverse(&mut |resources: &Resources| { - let Some(color_fonts) = &resources.color_fonts else { - return Ok(()); - }; - - for (color_font, font_slice) in color_fonts.iter() { - if out.contains_key(&font_slice) { - continue; - } - - // Allocate some IDs. - let subfont_id = chunk.alloc(); - let cmap_ref = chunk.alloc(); - let descriptor_ref = chunk.alloc(); - let widths_ref = chunk.alloc(); - - // And a map between glyph IDs and the instructions to draw this - // glyph. - let mut glyphs_to_instructions = Vec::new(); - - let start = font_slice.subfont * 256; - let end = (start + 256).min(color_font.glyphs.len()); - let glyph_count = end - start; - let subset = &color_font.glyphs[start..end]; - let mut widths = Vec::new(); - let mut gids = Vec::new(); - - let scale_factor = font_slice.font.ttf().units_per_em() as f32; - - // Write the instructions for each glyph. - for color_glyph in subset { - let instructions_stream_ref = chunk.alloc(); - let width = font_slice - .font - .advance(color_glyph.gid) - .unwrap_or(Em::new(0.0)) - .get() as f32 - * scale_factor; - widths.push(width); - chunk - .stream( - instructions_stream_ref, - color_glyph.instructions.content.wait(), - ) - .filter(Filter::FlateDecode); - - // Use this stream as instructions to draw the glyph. - glyphs_to_instructions.push(instructions_stream_ref); - gids.push(color_glyph.gid); - } - - // Determine the base font name. - gids.sort(); - let base_font = base_font_name(&font_slice.font, &gids); - - // Write the Type3 font object. - let mut pdf_font = chunk.type3_font(subfont_id); - pdf_font.name(Name(base_font.as_bytes())); - pdf_font.pair(Name(b"Resources"), color_fonts.resources.reference); - pdf_font.bbox(color_font.bbox); - pdf_font.matrix([1.0 / scale_factor, 0.0, 0.0, 1.0 / scale_factor, 0.0, 0.0]); - pdf_font.first_char(0); - pdf_font.last_char((glyph_count - 1) as u8); - pdf_font.pair(Name(b"Widths"), widths_ref); - pdf_font.to_unicode(cmap_ref); - pdf_font.font_descriptor(descriptor_ref); - - // Write the /CharProcs dictionary, that maps glyph names to - // drawing instructions. - let mut char_procs = pdf_font.char_procs(); - for (gid, instructions_ref) in glyphs_to_instructions.iter().enumerate() { - char_procs - .pair(Name(eco_format!("glyph{gid}").as_bytes()), *instructions_ref); - } - char_procs.finish(); - - // Write the /Encoding dictionary. - let names = (0..glyph_count) - .map(|gid| eco_format!("glyph{gid}")) - .collect::>(); - pdf_font - .encoding_custom() - .differences() - .consecutive(0, names.iter().map(|name| Name(name.as_bytes()))); - pdf_font.finish(); - - // Encode a CMAP to make it possible to search or copy glyphs. - let glyph_set = resources.color_glyph_sets.get(&font_slice.font).unwrap(); - let mut cmap = UnicodeCmap::new(CMAP_NAME, SYSTEM_INFO); - for (index, glyph) in subset.iter().enumerate() { - let Some(text) = glyph_set.get(&glyph.gid) else { - continue; - }; - - if !text.is_empty() { - cmap.pair_with_multiple(index as u8, text.chars()); - } - } - chunk.cmap(cmap_ref, &cmap.finish()).writing_mode(WMode::Horizontal); - - // Write the font descriptor. - write_font_descriptor( - &mut chunk, - descriptor_ref, - &font_slice.font, - &base_font, - ); - - // Write the widths array - chunk.indirect(widths_ref).array().items(widths); - - out.insert(font_slice, subfont_id); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// A mapping between `Font`s and all the corresponding `ColorFont`s. -/// -/// This mapping is one-to-many because there can only be 256 glyphs in a Type 3 -/// font, and fonts generally have more color glyphs than that. -pub struct ColorFontMap { - /// The mapping itself. - map: IndexMap, - /// The resources required to render the fonts in this map. - /// - /// For example, this can be the images for glyphs based on bitmaps or SVG. - pub resources: Resources, - /// The number of font slices (groups of 256 color glyphs), across all color - /// fonts. - total_slice_count: usize, -} - -/// A collection of Type3 font, belonging to the same TTF font. -pub struct ColorFont { - /// The IDs of each sub-slice of this font. They are the numbers after "Cf" - /// in the Resources dictionaries. - slice_ids: Vec, - /// The list of all color glyphs in this family. - /// - /// The index in this vector modulo 256 corresponds to the index in one of - /// the Type3 fonts in `refs` (the `n`-th in the vector, where `n` is the - /// quotient of the index divided by 256). - pub glyphs: Vec, - /// The global bounding box of the font. - pub bbox: Rect, - /// A mapping between glyph IDs and character indices in the `glyphs` - /// vector. - glyph_indices: HashMap, -} - -/// A single color glyph. -pub struct ColorGlyph { - /// The ID of the glyph. - pub gid: u16, - /// Instructions to draw the glyph. - pub instructions: content::Encoded, -} - -impl ColorFontMap<()> { - /// Creates a new empty mapping - pub fn new() -> Self { - Self { - map: IndexMap::new(), - total_slice_count: 0, - resources: Resources::default(), - } - } - - /// For a given glyph in a TTF font, give the ID of the Type3 font and the - /// index of the glyph inside of this Type3 font. - /// - /// If this is the first occurrence of this glyph in this font, it will - /// start its encoding and add it to the list of known glyphs. - pub fn get( - &mut self, - options: &PdfOptions, - text: &TextItemView, - glyph: &Glyph, - ) -> SourceResult<(usize, u8)> { - let font = &text.item.font; - let color_font = self.map.entry(font.clone()).or_insert_with(|| { - let global_bbox = font.ttf().global_bounding_box(); - let bbox = Rect::new( - font.to_em(global_bbox.x_min).to_font_units(), - font.to_em(global_bbox.y_min).to_font_units(), - font.to_em(global_bbox.x_max).to_font_units(), - font.to_em(global_bbox.y_max).to_font_units(), - ); - ColorFont { - bbox, - slice_ids: Vec::new(), - glyphs: Vec::new(), - glyph_indices: HashMap::new(), - } - }); - - Ok(if let Some(index_of_glyph) = color_font.glyph_indices.get(&glyph.id) { - // If we already know this glyph, return it. - (color_font.slice_ids[index_of_glyph / 256], *index_of_glyph as u8) - } else { - // Otherwise, allocate a new ColorGlyph in the font, and a new Type3 font - // if needed - let index = color_font.glyphs.len(); - if index % 256 == 0 { - color_font.slice_ids.push(self.total_slice_count); - self.total_slice_count += 1; - } - - let (frame, tofu) = glyph_frame(font, glyph.id); - if options.standards.pdfa && tofu { - bail!(failed_to_convert(text, glyph)); - } - - let width = font.advance(glyph.id).unwrap_or(Em::new(0.0)).get() - * font.units_per_em(); - let instructions = content::build( - options, - &mut self.resources, - &frame, - None, - Some(width as f32), - )?; - color_font.glyphs.push(ColorGlyph { gid: glyph.id, instructions }); - color_font.glyph_indices.insert(glyph.id, index); - - (color_font.slice_ids[index / 256], index as u8) - }) - } - - /// Assign references to the resource dictionary used by this set of color - /// fonts. - pub fn with_refs(self, refs: &ResourcesRefs) -> ColorFontMap { - ColorFontMap { - map: self.map, - resources: self.resources.with_refs(refs), - total_slice_count: self.total_slice_count, - } - } -} - -impl ColorFontMap { - /// Iterate over all Type3 fonts. - /// - /// Each item of this iterator maps to a Type3 font: it contains - /// at most 256 glyphs. A same TTF font can yield multiple Type3 fonts. - pub fn iter(&self) -> ColorFontMapIter<'_, R> { - ColorFontMapIter { map: self, font_index: 0, slice_index: 0 } - } -} - -/// Iterator over a [`ColorFontMap`]. -/// -/// See [`ColorFontMap::iter`]. -pub struct ColorFontMapIter<'a, R> { - /// The map over which to iterate - map: &'a ColorFontMap, - /// The index of TTF font on which we currently iterate - font_index: usize, - /// The sub-font (slice of at most 256 glyphs) at which we currently are. - slice_index: usize, -} - -impl<'a, R> Iterator for ColorFontMapIter<'a, R> { - type Item = (&'a ColorFont, ColorFontSlice); - - fn next(&mut self) -> Option { - let (font, color_font) = self.map.map.get_index(self.font_index)?; - let slice_count = (color_font.glyphs.len() / 256) + 1; - - if self.slice_index >= slice_count { - self.font_index += 1; - self.slice_index = 0; - return self.next(); - } - - let slice = ColorFontSlice { font: font.clone(), subfont: self.slice_index }; - self.slice_index += 1; - Some((color_font, slice)) - } -} - -/// A set of at most 256 glyphs (a limit imposed on Type3 fonts by the PDF -/// specification) that represents a part of a TTF font. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct ColorFontSlice { - /// The original TTF font. - pub font: Font, - /// The index of the Type3 font, among all those that are necessary to - /// represent the subset of the TTF font we are interested in. - pub subfont: usize, -} - -/// The error when the glyph could not be converted. -#[cold] -fn failed_to_convert(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { - let mut diag = error!( - glyph.span.0, - "the glyph for {} could not be exported", - text.glyph_text(glyph).repr() - ); - - if text.item.font.ttf().tables().cff2.is_some() { - diag.hint("CFF2 fonts are not currently supported"); - } - - diag -} diff --git a/crates/typst-pdf/src/content.rs b/crates/typst-pdf/src/content.rs deleted file mode 100644 index 8b7517f51..000000000 --- a/crates/typst-pdf/src/content.rs +++ /dev/null @@ -1,823 +0,0 @@ -//! Generic writer for PDF content. -//! -//! It is used to write page contents, color glyph instructions, and tilings. -//! -//! See also [`pdf_writer::Content`]. - -use ecow::eco_format; -use pdf_writer::types::{ - ColorSpaceOperand, LineCapStyle, LineJoinStyle, TextRenderingMode, -}; -use pdf_writer::writers::PositionedItems; -use pdf_writer::{Content, Finish, Name, Rect, Str}; -use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; -use typst_library::foundations::Repr; -use typst_library::layout::{ - Abs, Em, Frame, FrameItem, GroupItem, Point, Ratio, Size, Transform, -}; -use typst_library::model::Destination; -use typst_library::text::color::should_outline; -use typst_library::text::{Font, Glyph, TextItem, TextItemView}; -use typst_library::visualize::{ - Curve, CurveItem, FillRule, FixedStroke, Geometry, Image, LineCap, LineJoin, Paint, - Shape, -}; -use typst_syntax::Span; -use typst_utils::{Deferred, Numeric, SliceExt}; - -use crate::color::PaintEncode; -use crate::color_font::ColorFontMap; -use crate::extg::ExtGState; -use crate::image::deferred_image; -use crate::resources::Resources; -use crate::{deflate_deferred, AbsExt, ContentExt, EmExt, PdfOptions, StrExt}; - -/// Encode a [`Frame`] into a content stream. -/// -/// The resources that were used in the stream will be added to `resources`. -/// -/// `color_glyph_width` should be `None` unless the `Frame` represents a [color -/// glyph]. -/// -/// [color glyph]: `crate::color_font` -pub fn build( - options: &PdfOptions, - resources: &mut Resources<()>, - frame: &Frame, - fill: Option, - color_glyph_width: Option, -) -> SourceResult { - let size = frame.size(); - let mut ctx = Builder::new(options, resources, size); - - if let Some(width) = color_glyph_width { - ctx.content.start_color_glyph(width); - } - - // Make the coordinate system start at the top-left. - ctx.transform( - // Make the Y axis go upwards - Transform::scale(Ratio::one(), -Ratio::one()) - // Also move the origin to the top left corner - .post_concat(Transform::translate(Abs::zero(), size.y)), - ); - - if let Some(fill) = fill { - let shape = Geometry::Rect(frame.size()).filled(fill); - write_shape(&mut ctx, Point::zero(), &shape)?; - } - - // Encode the frame into the content stream. - write_frame(&mut ctx, frame)?; - - Ok(Encoded { - size, - content: deflate_deferred(ctx.content.finish()), - uses_opacities: ctx.uses_opacities, - links: ctx.links, - }) -} - -/// An encoded content stream. -pub struct Encoded { - /// The dimensions of the content. - pub size: Size, - /// The actual content stream. - pub content: Deferred>, - /// Whether the content opacities. - pub uses_opacities: bool, - /// Links in the PDF coordinate system. - pub links: Vec<(Destination, Rect)>, -} - -/// An exporter for a single PDF content stream. -/// -/// Content streams are a series of PDF commands. They can reference external -/// objects only through resources. -/// -/// Content streams can be used for page contents, but also to describe color -/// glyphs and tilings. -pub struct Builder<'a, R = ()> { - /// Settings for PDF export. - pub(crate) options: &'a PdfOptions<'a>, - /// A list of all resources that are used in the content stream. - pub(crate) resources: &'a mut Resources, - /// The PDF content stream that is being built. - pub content: Content, - /// Current graphic state. - state: State, - /// Stack of saved graphic states. - saves: Vec, - /// Whether any stroke or fill was not totally opaque. - uses_opacities: bool, - /// All clickable links that are present in this content. - links: Vec<(Destination, Rect)>, -} - -impl<'a, R> Builder<'a, R> { - /// Create a new content builder. - pub fn new( - options: &'a PdfOptions<'a>, - resources: &'a mut Resources, - size: Size, - ) -> Self { - Builder { - options, - resources, - uses_opacities: false, - content: Content::new(), - state: State::new(size), - saves: vec![], - links: vec![], - } - } -} - -/// A simulated graphics state used to deduplicate graphics state changes and -/// keep track of the current transformation matrix for link annotations. -#[derive(Debug, Clone)] -struct State { - /// The transform of the current item. - transform: Transform, - /// The transform of first hard frame in the hierarchy. - container_transform: Transform, - /// The size of the first hard frame in the hierarchy. - size: Size, - /// The current font. - font: Option<(Font, Abs)>, - /// The current fill paint. - fill: Option, - /// The color space of the current fill paint. - fill_space: Option>, - /// The current external graphic state. - external_graphics_state: ExtGState, - /// The current stroke paint. - stroke: Option, - /// The color space of the current stroke paint. - stroke_space: Option>, - /// The current text rendering mode. - text_rendering_mode: TextRenderingMode, -} - -impl State { - /// Creates a new, clean state for a given `size`. - pub fn new(size: Size) -> Self { - Self { - transform: Transform::identity(), - container_transform: Transform::identity(), - size, - font: None, - fill: None, - fill_space: None, - external_graphics_state: ExtGState::default(), - stroke: None, - stroke_space: None, - text_rendering_mode: TextRenderingMode::Fill, - } - } - - /// Creates the [`Transforms`] structure for the current item. - pub fn transforms(&self, size: Size, pos: Point) -> Transforms { - Transforms { - transform: self.transform.pre_concat(Transform::translate(pos.x, pos.y)), - container_transform: self.container_transform, - container_size: self.size, - size, - } - } -} - -/// Subset of the state used to calculate the transform of gradients and tilings. -#[derive(Debug, Clone, Copy)] -pub(super) struct Transforms { - /// The transform of the current item. - pub transform: Transform, - /// The transform of first hard frame in the hierarchy. - pub container_transform: Transform, - /// The size of the first hard frame in the hierarchy. - pub container_size: Size, - /// The size of the item. - pub size: Size, -} - -impl Builder<'_, ()> { - fn save_state(&mut self) -> SourceResult<()> { - self.saves.push(self.state.clone()); - self.content.save_state_checked() - } - - fn restore_state(&mut self) { - self.content.restore_state(); - self.state = self.saves.pop().expect("missing state save"); - } - - fn set_external_graphics_state(&mut self, graphics_state: &ExtGState) { - let current_state = &self.state.external_graphics_state; - if current_state != graphics_state { - let index = self.resources.ext_gs.insert(*graphics_state); - let name = eco_format!("Gs{index}"); - self.content.set_parameters(Name(name.as_bytes())); - - self.state.external_graphics_state = *graphics_state; - if graphics_state.uses_opacities() { - self.uses_opacities = true; - } - } - } - - fn set_opacities(&mut self, stroke: Option<&FixedStroke>, fill: Option<&Paint>) { - let get_opacity = |paint: &Paint| { - let color = match paint { - Paint::Solid(color) => *color, - Paint::Gradient(_) | Paint::Tiling(_) => return 255, - }; - - color.alpha().map_or(255, |v| (v * 255.0).round() as u8) - }; - - let stroke_opacity = stroke.map_or(255, |stroke| get_opacity(&stroke.paint)); - let fill_opacity = fill.map_or(255, get_opacity); - self.set_external_graphics_state(&ExtGState { stroke_opacity, fill_opacity }); - } - - fn reset_opacities(&mut self) { - self.set_external_graphics_state(&ExtGState { - stroke_opacity: 255, - fill_opacity: 255, - }); - } - - pub fn transform(&mut self, transform: Transform) { - let Transform { sx, ky, kx, sy, tx, ty } = transform; - self.state.transform = self.state.transform.pre_concat(transform); - if self.state.container_transform.is_identity() { - self.state.container_transform = self.state.transform; - } - self.content.transform([ - sx.get() as _, - ky.get() as _, - kx.get() as _, - sy.get() as _, - tx.to_f32(), - ty.to_f32(), - ]); - } - - fn group_transform(&mut self, transform: Transform) { - self.state.container_transform = - self.state.container_transform.pre_concat(transform); - } - - fn set_font(&mut self, font: &Font, size: Abs) { - if self.state.font.as_ref().map(|(f, s)| (f, *s)) != Some((font, size)) { - let index = self.resources.fonts.insert(font.clone()); - let name = eco_format!("F{index}"); - self.content.set_font(Name(name.as_bytes()), size.to_f32()); - self.state.font = Some((font.clone(), size)); - } - } - - fn size(&mut self, size: Size) { - self.state.size = size; - } - - fn set_fill( - &mut self, - fill: &Paint, - on_text: bool, - transforms: Transforms, - ) -> SourceResult<()> { - if self.state.fill.as_ref() != Some(fill) - || matches!(self.state.fill, Some(Paint::Gradient(_))) - { - fill.set_as_fill(self, on_text, transforms)?; - self.state.fill = Some(fill.clone()); - } - Ok(()) - } - - pub fn set_fill_color_space(&mut self, space: Name<'static>) { - if self.state.fill_space != Some(space) { - self.content.set_fill_color_space(ColorSpaceOperand::Named(space)); - self.state.fill_space = Some(space); - } - } - - pub fn reset_fill_color_space(&mut self) { - self.state.fill_space = None; - } - - fn set_stroke( - &mut self, - stroke: &FixedStroke, - on_text: bool, - transforms: Transforms, - ) -> SourceResult<()> { - if self.state.stroke.as_ref() != Some(stroke) - || matches!( - self.state.stroke.as_ref().map(|s| &s.paint), - Some(Paint::Gradient(_)) - ) - { - let FixedStroke { paint, thickness, cap, join, dash, miter_limit } = stroke; - paint.set_as_stroke(self, on_text, transforms)?; - - self.content.set_line_width(thickness.to_f32()); - if self.state.stroke.as_ref().map(|s| &s.cap) != Some(cap) { - self.content.set_line_cap(to_pdf_line_cap(*cap)); - } - if self.state.stroke.as_ref().map(|s| &s.join) != Some(join) { - self.content.set_line_join(to_pdf_line_join(*join)); - } - if self.state.stroke.as_ref().map(|s| &s.dash) != Some(dash) { - if let Some(dash) = dash { - self.content.set_dash_pattern( - dash.array.iter().map(|l| l.to_f32()), - dash.phase.to_f32(), - ); - } else { - self.content.set_dash_pattern([], 0.0); - } - } - if self.state.stroke.as_ref().map(|s| &s.miter_limit) != Some(miter_limit) { - self.content.set_miter_limit(miter_limit.get() as f32); - } - self.state.stroke = Some(stroke.clone()); - } - - Ok(()) - } - - pub fn set_stroke_color_space(&mut self, space: Name<'static>) { - if self.state.stroke_space != Some(space) { - self.content.set_stroke_color_space(ColorSpaceOperand::Named(space)); - self.state.stroke_space = Some(space); - } - } - - pub fn reset_stroke_color_space(&mut self) { - self.state.stroke_space = None; - } - - fn set_text_rendering_mode(&mut self, mode: TextRenderingMode) { - if self.state.text_rendering_mode != mode { - self.content.set_text_rendering_mode(mode); - self.state.text_rendering_mode = mode; - } - } -} - -/// Encode a frame into the content stream. -pub(crate) fn write_frame(ctx: &mut Builder, frame: &Frame) -> SourceResult<()> { - for &(pos, ref item) in frame.items() { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - match item { - FrameItem::Group(group) => write_group(ctx, pos, group)?, - FrameItem::Text(text) => write_text(ctx, pos, text)?, - FrameItem::Shape(shape, _) => write_shape(ctx, pos, shape)?, - FrameItem::Image(image, size, span) => { - write_image(ctx, x, y, image, *size, *span)? - } - FrameItem::Link(dest, size) => write_link(ctx, pos, dest, *size), - FrameItem::Tag(_) => {} - } - } - Ok(()) -} - -/// Encode a group into the content stream. -fn write_group(ctx: &mut Builder, pos: Point, group: &GroupItem) -> SourceResult<()> { - let translation = Transform::translate(pos.x, pos.y); - - ctx.save_state()?; - - if group.frame.kind().is_hard() { - ctx.group_transform( - ctx.state - .transform - .post_concat(ctx.state.container_transform.invert().unwrap()) - .pre_concat(translation) - .pre_concat(group.transform), - ); - ctx.size(group.frame.size()); - } - - ctx.transform(translation.pre_concat(group.transform)); - if let Some(clip_curve) = &group.clip { - write_curve(ctx, 0.0, 0.0, clip_curve); - ctx.content.clip_nonzero(); - ctx.content.end_path(); - } - - write_frame(ctx, &group.frame)?; - ctx.restore_state(); - - Ok(()) -} - -/// Encode a text run into the content stream. -fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<()> { - if ctx.options.standards.pdfa && text.font.info().is_last_resort() { - bail!( - Span::find(text.glyphs.iter().map(|g| g.span.0)), - "the text {} could not be displayed with any font", - &text.text, - ); - } - - let outline_glyphs = - text.glyphs.iter().filter(|g| should_outline(&text.font, g)).count(); - - if outline_glyphs == text.glyphs.len() { - write_normal_text(ctx, pos, TextItemView::full(text))?; - } else if outline_glyphs == 0 { - write_complex_glyphs(ctx, pos, TextItemView::full(text))?; - } else { - // Otherwise we need to split it into smaller text runs. - let mut offset = 0; - let mut position_in_run = Abs::zero(); - for (should_outline, sub_run) in - text.glyphs.group_by_key(|g| should_outline(&text.font, g)) - { - let end = offset + sub_run.len(); - - // Build a sub text-run - let text_item_view = TextItemView::from_glyph_range(text, offset..end); - - // Adjust the position of the run on the line - let pos = pos + Point::new(position_in_run, Abs::zero()); - position_in_run += text_item_view.width(); - offset = end; - - // Actually write the sub text-run. - if should_outline { - write_normal_text(ctx, pos, text_item_view)?; - } else { - write_complex_glyphs(ctx, pos, text_item_view)?; - } - } - } - - Ok(()) -} - -/// Encodes a text run (without any color glyph) into the content stream. -fn write_normal_text( - ctx: &mut Builder, - pos: Point, - text: TextItemView, -) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - *ctx.resources.languages.entry(text.item.lang).or_insert(0) += text.glyph_range.len(); - - let glyph_set = ctx.resources.glyph_sets.entry(text.item.font.clone()).or_default(); - for g in text.glyphs() { - glyph_set.entry(g.id).or_insert_with(|| text.glyph_text(g)); - } - - let fill_transform = ctx.state.transforms(Size::zero(), pos); - ctx.set_fill(&text.item.fill, true, fill_transform)?; - - let stroke = text.item.stroke.as_ref().and_then(|stroke| { - if stroke.thickness.to_f32() > 0.0 { - Some(stroke) - } else { - None - } - }); - - if let Some(stroke) = stroke { - ctx.set_stroke(stroke, true, fill_transform)?; - ctx.set_text_rendering_mode(TextRenderingMode::FillStroke); - } else { - ctx.set_text_rendering_mode(TextRenderingMode::Fill); - } - - ctx.set_font(&text.item.font, text.item.size); - ctx.set_opacities(text.item.stroke.as_ref(), Some(&text.item.fill)); - ctx.content.begin_text(); - - // Position the text. - ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]); - - let mut positioned = ctx.content.show_positioned(); - let mut items = positioned.items(); - let mut adjustment = Em::zero(); - let mut encoded = vec![]; - - let glyph_remapper = ctx - .resources - .glyph_remappers - .entry(text.item.font.clone()) - .or_default(); - - // Write the glyphs with kerning adjustments. - for glyph in text.glyphs() { - if ctx.options.standards.pdfa && glyph.id == 0 { - bail!(tofu(&text, glyph)); - } - - adjustment += glyph.x_offset; - - if !adjustment.is_zero() { - if !encoded.is_empty() { - show_text(&mut items, &encoded); - encoded.clear(); - } - - items.adjust(-adjustment.to_font_units()); - adjustment = Em::zero(); - } - - // In PDF, we use CIDs to index the glyphs in a font, not GIDs. What a - // CID actually refers to depends on the type of font we are embedding: - // - // - For TrueType fonts, the CIDs are defined by an external mapping. - // - For SID-keyed CFF fonts, the CID is the same as the GID in the font. - // - For CID-keyed CFF fonts, the CID refers to the CID in the font. - // - // (See in the PDF-spec for more details on this.) - // - // However, in our case: - // - We use the identity-mapping for TrueType fonts. - // - SID-keyed fonts will get converted into CID-keyed fonts by the - // subsetter. - // - CID-keyed fonts will be rewritten in a way so that the mapping - // between CID and GID is always the identity mapping, regardless of - // the mapping before. - // - // Because of this, we can always use the remapped GID as the CID, - // regardless of which type of font we are actually embedding. - let cid = glyph_remapper.remap(glyph.id); - encoded.push((cid >> 8) as u8); - encoded.push((cid & 0xff) as u8); - - if let Some(advance) = text.item.font.advance(glyph.id) { - adjustment += glyph.x_advance - advance; - } - - adjustment -= glyph.x_offset; - } - - if !encoded.is_empty() { - show_text(&mut items, &encoded); - } - - items.finish(); - positioned.finish(); - ctx.content.end_text(); - - Ok(()) -} - -/// Shows text, ensuring that each individual string doesn't exceed the -/// implementation limits. -fn show_text(items: &mut PositionedItems, encoded: &[u8]) { - for chunk in encoded.chunks(Str::PDFA_LIMIT) { - items.show(Str(chunk)); - } -} - -/// Encodes a text run made only of color glyphs into the content stream -fn write_complex_glyphs( - ctx: &mut Builder, - pos: Point, - text: TextItemView, -) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - let mut last_font = None; - - ctx.reset_opacities(); - - ctx.content.begin_text(); - ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]); - // So that the next call to ctx.set_font() will change the font to one that - // displays regular glyphs and not color glyphs. - ctx.state.font = None; - - let glyph_set = ctx - .resources - .color_glyph_sets - .entry(text.item.font.clone()) - .or_default(); - - for glyph in text.glyphs() { - if ctx.options.standards.pdfa && glyph.id == 0 { - bail!(tofu(&text, glyph)); - } - - // Retrieve the Type3 font reference and the glyph index in the font. - let color_fonts = ctx - .resources - .color_fonts - .get_or_insert_with(|| Box::new(ColorFontMap::new())); - - let (font, index) = color_fonts.get(ctx.options, &text, glyph)?; - - if last_font != Some(font) { - ctx.content.set_font( - Name(eco_format!("Cf{}", font).as_bytes()), - text.item.size.to_f32(), - ); - last_font = Some(font); - } - - ctx.content.show(Str(&[index])); - - glyph_set.entry(glyph.id).or_insert_with(|| text.glyph_text(glyph)); - } - ctx.content.end_text(); - - Ok(()) -} - -/// Encode a geometrical shape into the content stream. -fn write_shape(ctx: &mut Builder, pos: Point, shape: &Shape) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - let stroke = shape.stroke.as_ref().and_then(|stroke| { - if stroke.thickness.to_f32() > 0.0 { - Some(stroke) - } else { - None - } - }); - - if shape.fill.is_none() && stroke.is_none() { - return Ok(()); - } - - if let Some(fill) = &shape.fill { - ctx.set_fill(fill, false, ctx.state.transforms(shape.geometry.bbox_size(), pos))?; - } - - if let Some(stroke) = stroke { - ctx.set_stroke( - stroke, - false, - ctx.state.transforms(shape.geometry.bbox_size(), pos), - )?; - } - - ctx.set_opacities(stroke, shape.fill.as_ref()); - - match &shape.geometry { - Geometry::Line(target) => { - let dx = target.x.to_f32(); - let dy = target.y.to_f32(); - ctx.content.move_to(x, y); - ctx.content.line_to(x + dx, y + dy); - } - Geometry::Rect(size) => { - let w = size.x.to_f32(); - let h = size.y.to_f32(); - if w.abs() > f32::EPSILON && h.abs() > f32::EPSILON { - ctx.content.rect(x, y, w, h); - } - } - Geometry::Curve(curve) => { - write_curve(ctx, x, y, curve); - } - } - - match (&shape.fill, &shape.fill_rule, stroke) { - (None, _, None) => unreachable!(), - (Some(_), FillRule::NonZero, None) => ctx.content.fill_nonzero(), - (Some(_), FillRule::EvenOdd, None) => ctx.content.fill_even_odd(), - (None, _, Some(_)) => ctx.content.stroke(), - (Some(_), FillRule::NonZero, Some(_)) => ctx.content.fill_nonzero_and_stroke(), - (Some(_), FillRule::EvenOdd, Some(_)) => ctx.content.fill_even_odd_and_stroke(), - }; - - Ok(()) -} - -/// Encode a curve into the content stream. -fn write_curve(ctx: &mut Builder, x: f32, y: f32, curve: &Curve) { - for elem in &curve.0 { - match elem { - CurveItem::Move(p) => ctx.content.move_to(x + p.x.to_f32(), y + p.y.to_f32()), - CurveItem::Line(p) => ctx.content.line_to(x + p.x.to_f32(), y + p.y.to_f32()), - CurveItem::Cubic(p1, p2, p3) => ctx.content.cubic_to( - x + p1.x.to_f32(), - y + p1.y.to_f32(), - x + p2.x.to_f32(), - y + p2.y.to_f32(), - x + p3.x.to_f32(), - y + p3.y.to_f32(), - ), - CurveItem::Close => ctx.content.close_path(), - }; - } -} - -/// Encode a vector or raster image into the content stream. -fn write_image( - ctx: &mut Builder, - x: f32, - y: f32, - image: &Image, - size: Size, - span: Span, -) -> SourceResult<()> { - let index = ctx.resources.images.insert(image.clone()); - ctx.resources.deferred_images.entry(index).or_insert_with(|| { - let (image, color_space) = - deferred_image(image.clone(), ctx.options.standards.pdfa); - if let Some(color_space) = color_space { - ctx.resources.colors.mark_as_used(color_space); - } - (image, span) - }); - - ctx.reset_opacities(); - - let name = eco_format!("Im{index}"); - let w = size.x.to_f32(); - let h = size.y.to_f32(); - ctx.content.save_state_checked()?; - ctx.content.transform([w, 0.0, 0.0, -h, x, y + h]); - - if let Some(alt) = image.alt() { - if ctx.options.standards.pdfa && alt.len() > Str::PDFA_LIMIT { - bail!(span, "the image's alt text is too long"); - } - - let mut image_span = - ctx.content.begin_marked_content_with_properties(Name(b"Span")); - let mut image_alt = image_span.properties(); - image_alt.pair(Name(b"Alt"), Str(alt.as_bytes())); - image_alt.finish(); - image_span.finish(); - - ctx.content.x_object(Name(name.as_bytes())); - ctx.content.end_marked_content(); - } else { - ctx.content.x_object(Name(name.as_bytes())); - } - - ctx.content.restore_state(); - Ok(()) -} - -/// Save a link for later writing in the annotations dictionary. -fn write_link(ctx: &mut Builder, pos: Point, dest: &Destination, size: Size) { - let mut min_x = Abs::inf(); - let mut min_y = Abs::inf(); - let mut max_x = -Abs::inf(); - let mut max_y = -Abs::inf(); - - // Compute the bounding box of the transformed link. - for point in [ - pos, - pos + Point::with_x(size.x), - pos + Point::with_y(size.y), - pos + size.to_point(), - ] { - let t = point.transform(ctx.state.transform); - min_x.set_min(t.x); - min_y.set_min(t.y); - max_x.set_max(t.x); - max_y.set_max(t.y); - } - - let x1 = min_x.to_f32(); - let x2 = max_x.to_f32(); - let y1 = max_y.to_f32(); - let y2 = min_y.to_f32(); - let rect = Rect::new(x1, y1, x2, y2); - - ctx.links.push((dest.clone(), rect)); -} - -fn to_pdf_line_cap(cap: LineCap) -> LineCapStyle { - match cap { - LineCap::Butt => LineCapStyle::ButtCap, - LineCap::Round => LineCapStyle::RoundCap, - LineCap::Square => LineCapStyle::ProjectingSquareCap, - } -} - -fn to_pdf_line_join(join: LineJoin) -> LineJoinStyle { - match join { - LineJoin::Miter => LineJoinStyle::MiterJoin, - LineJoin::Round => LineJoinStyle::RoundJoin, - LineJoin::Bevel => LineJoinStyle::BevelJoin, - } -} - -/// The error when there is a tofu glyph. -#[cold] -fn tofu(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { - error!( - glyph.span.0, - "the text {} could not be displayed with any font", - text.glyph_text(glyph).repr(), - ) -} diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs new file mode 100644 index 000000000..f5ca31730 --- /dev/null +++ b/crates/typst-pdf/src/convert.rs @@ -0,0 +1,661 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::num::NonZeroU64; + +use ecow::{eco_format, EcoVec}; +use krilla::annotation::Annotation; +use krilla::configure::{Configuration, ValidationError, Validator}; +use krilla::destination::{NamedDestination, XyzDestination}; +use krilla::embed::EmbedError; +use krilla::error::KrillaError; +use krilla::geom::PathBuilder; +use krilla::page::{PageLabel, PageSettings}; +use krilla::surface::Surface; +use krilla::{Document, SerializeSettings}; +use krilla_svg::render_svg_glyph; +use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; +use typst_library::foundations::NativeElement; +use typst_library::introspection::Location; +use typst_library::layout::{ + Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, +}; +use typst_library::model::HeadingElem; +use typst_library::text::{Font, Lang}; +use typst_library::visualize::{Geometry, Paint}; +use typst_syntax::Span; + +use crate::embed::embed_files; +use crate::image::handle_image; +use crate::link::handle_link; +use crate::metadata::build_metadata; +use crate::outline::build_outline; +use crate::page::PageLabelExt; +use crate::shape::handle_shape; +use crate::text::handle_text; +use crate::util::{convert_path, display_font, AbsExt, TransformExt}; +use crate::PdfOptions; + +#[typst_macros::time(name = "convert document")] +pub fn convert( + typst_document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult> { + let settings = SerializeSettings { + compress_content_streams: true, + no_device_cs: true, + ascii_compatible: false, + xmp_metadata: true, + cmyk_profile: None, + configuration: options.standards.config, + enable_tagging: false, + render_svg_glyph_fn: render_svg_glyph, + }; + + let mut document = Document::new_with(settings); + let page_index_converter = PageIndexConverter::new(typst_document, options); + let named_destinations = + collect_named_destinations(typst_document, &page_index_converter); + let mut gc = GlobalContext::new( + typst_document, + options, + named_destinations, + page_index_converter, + ); + + convert_pages(&mut gc, &mut document)?; + embed_files(typst_document, &mut document)?; + + document.set_outline(build_outline(&gc)); + document.set_metadata(build_metadata(&gc)); + + finish(document, gc, options.standards.config) +} + +fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> { + for (i, typst_page) in gc.document.pages.iter().enumerate() { + if gc.page_index_converter.pdf_page_index(i).is_none() { + // Don't export this page. + continue; + } else { + let mut settings = PageSettings::new( + typst_page.frame.width().to_f32(), + typst_page.frame.height().to_f32(), + ); + + if let Some(label) = typst_page + .numbering + .as_ref() + .and_then(|num| PageLabel::generate(num, typst_page.number)) + .or_else(|| { + // When some pages were ignored from export, we show a page label with + // the correct real (not logical) page number. + // This is for consistency with normal output when pages have no numbering + // and all are exported: the final PDF page numbers always correspond to + // the real (not logical) page numbers. Here, the final PDF page number + // will differ, but we can at least use labels to indicate what was + // the corresponding real page number in the Typst document. + gc.page_index_converter + .has_skipped_pages() + .then(|| PageLabel::arabic((i + 1) as u64)) + }) + { + settings = settings.with_page_label(label); + } + + let mut page = document.start_page_with(settings); + let mut surface = page.surface(); + let mut fc = FrameContext::new(typst_page.frame.size()); + + handle_frame( + &mut fc, + &typst_page.frame, + typst_page.fill_or_transparent(), + &mut surface, + gc, + )?; + + surface.finish(); + + for annotation in fc.annotations { + page.add_annotation(annotation); + } + } + } + + Ok(()) +} + +/// A state allowing us to keep track of transforms and container sizes, +/// which is mainly needed to resolve gradients and patterns correctly. +#[derive(Debug, Clone)] +pub(crate) struct State { + /// The current transform. + transform: Transform, + /// The transform of first hard frame in the hierarchy. + container_transform: Transform, + /// The size of the first hard frame in the hierarchy. + container_size: Size, +} + +impl State { + /// Creates a new, clean state for a given `size`. + fn new(size: Size) -> Self { + Self { + transform: Transform::identity(), + container_transform: Transform::identity(), + container_size: size, + } + } + + pub(crate) fn register_container(&mut self, size: Size) { + self.container_transform = self.transform; + self.container_size = size; + } + + pub(crate) fn pre_concat(&mut self, transform: Transform) { + self.transform = self.transform.pre_concat(transform); + } + + pub(crate) fn transform(&self) -> Transform { + self.transform + } + + pub(crate) fn container_transform(&self) -> Transform { + self.container_transform + } + + pub(crate) fn container_size(&self) -> Size { + self.container_size + } +} + +/// Context needed for converting a single frame. +pub(crate) struct FrameContext { + states: Vec, + annotations: Vec, +} + +impl FrameContext { + pub(crate) fn new(size: Size) -> Self { + Self { + states: vec![State::new(size)], + annotations: vec![], + } + } + + pub(crate) fn push(&mut self) { + self.states.push(self.states.last().unwrap().clone()); + } + + pub(crate) fn pop(&mut self) { + self.states.pop(); + } + + pub(crate) fn state(&self) -> &State { + self.states.last().unwrap() + } + + pub(crate) fn state_mut(&mut self) -> &mut State { + self.states.last_mut().unwrap() + } + + pub(crate) fn push_annotation(&mut self, annotation: Annotation) { + self.annotations.push(annotation); + } +} + +/// Globally needed context for converting a typst document. +pub(crate) struct GlobalContext<'a> { + /// Cache the conversion between krilla and Typst fonts (forward and backward). + pub(crate) fonts_forward: HashMap, + pub(crate) fonts_backward: HashMap, + /// Mapping between images and their span. + // Note: In theory, the same image can have multiple spans + // if it appears in the document multiple times. We just store the + // first appearance, though. + pub(crate) image_to_spans: HashMap, + /// The spans of all images that appear in the document. We use this so + /// we can give more accurate error messages. + pub(crate) image_spans: HashSet, + /// The document to convert. + pub(crate) document: &'a PagedDocument, + /// Options for PDF export. + pub(crate) options: &'a PdfOptions<'a>, + /// Mapping between locations in the document and named destinations. + pub(crate) loc_to_names: HashMap, + /// The languages used throughout the document. + pub(crate) languages: BTreeMap, + pub(crate) page_index_converter: PageIndexConverter, +} + +impl<'a> GlobalContext<'a> { + pub(crate) fn new( + document: &'a PagedDocument, + options: &'a PdfOptions, + loc_to_names: HashMap, + page_index_converter: PageIndexConverter, + ) -> GlobalContext<'a> { + Self { + fonts_forward: HashMap::new(), + fonts_backward: HashMap::new(), + document, + options, + loc_to_names, + image_to_spans: HashMap::new(), + image_spans: HashSet::new(), + languages: BTreeMap::new(), + page_index_converter, + } + } +} + +#[typst_macros::time(name = "handle page")] +pub(crate) fn handle_frame( + fc: &mut FrameContext, + frame: &Frame, + fill: Option, + surface: &mut Surface, + gc: &mut GlobalContext, +) -> SourceResult<()> { + fc.push(); + + if frame.kind().is_hard() { + fc.state_mut().register_container(frame.size()); + } + + if let Some(fill) = fill { + let shape = Geometry::Rect(frame.size()).filled(fill); + handle_shape(fc, &shape, surface, gc, Span::detached())?; + } + + for (point, item) in frame.items() { + fc.push(); + fc.state_mut().pre_concat(Transform::translate(point.x, point.y)); + + match item { + FrameItem::Group(g) => handle_group(fc, g, surface, gc)?, + FrameItem::Text(t) => handle_text(fc, t, surface, gc)?, + FrameItem::Shape(s, span) => handle_shape(fc, s, surface, gc, *span)?, + FrameItem::Image(image, size, span) => { + handle_image(gc, fc, image, *size, surface, *span)? + } + FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), + FrameItem::Tag(_) => {} + } + + fc.pop(); + } + + fc.pop(); + + Ok(()) +} + +pub(crate) fn handle_group( + fc: &mut FrameContext, + group: &GroupItem, + surface: &mut Surface, + context: &mut GlobalContext, +) -> SourceResult<()> { + fc.push(); + fc.state_mut().pre_concat(group.transform); + + let clip_path = group + .clip + .as_ref() + .and_then(|p| { + let mut builder = PathBuilder::new(); + convert_path(p, &mut builder); + builder.finish() + }) + .and_then(|p| p.transform(fc.state().transform.to_krilla())); + + if let Some(clip_path) = &clip_path { + surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero); + } + + handle_frame(fc, &group.frame, None, surface, context)?; + + if clip_path.is_some() { + surface.pop(); + } + + fc.pop(); + + Ok(()) +} + +#[typst_macros::time(name = "finish export")] +/// Finish a krilla document and handle export errors. +fn finish( + document: Document, + gc: GlobalContext, + configuration: Configuration, +) -> SourceResult> { + let validator = configuration.validator(); + + match document.finish() { + Ok(r) => Ok(r), + Err(e) => match e { + KrillaError::Font(f, s) => { + let font_str = display_font(gc.fonts_backward.get(&f).unwrap()); + bail!( + Span::detached(), + "failed to process font {font_str}: {s}"; + hint: "make sure the font is valid"; + hint: "the used font might be unsupported by Typst" + ); + } + KrillaError::Validation(ve) => { + let errors = ve + .iter() + .map(|e| convert_error(&gc, validator, e)) + .collect::>(); + Err(errors) + } + KrillaError::Image(_, loc) => { + let span = to_span(loc); + bail!(span, "failed to process image"); + } + KrillaError::SixteenBitImage(image, _) => { + let span = gc.image_to_spans.get(&image).unwrap(); + bail!( + *span, "16 bit images are not supported in this export mode"; + hint: "convert the image to 8 bit instead" + ) + } + }, + } +} + +/// Converts a krilla error into a Typst error. +fn convert_error( + gc: &GlobalContext, + validator: Validator, + error: &ValidationError, +) -> SourceDiagnostic { + let prefix = eco_format!("{} error:", validator.as_str()); + match error { + ValidationError::TooLongString => error!( + Span::detached(), + "{prefix} a PDF string is longer than 32767 characters"; + hint: "ensure title and author names are short enough" + ), + // Should in theory never occur, as krilla always trims font names. + ValidationError::TooLongName => error!( + Span::detached(), + "{prefix} a PDF name is longer than 127 characters"; + hint: "perhaps a font name is too long" + ), + + ValidationError::TooLongArray => error!( + Span::detached(), + "{prefix} a PDF array is longer than 8191 elements"; + hint: "this can happen if you have a very long text in a single line" + ), + ValidationError::TooLongDictionary => error!( + Span::detached(), + "{prefix} a PDF dictionary has more than 4095 entries"; + hint: "try reducing the complexity of your document" + ), + ValidationError::TooLargeFloat => error!( + Span::detached(), + "{prefix} a PDF floating point number is larger than the allowed limit"; + hint: "try exporting with a higher PDF version" + ), + ValidationError::TooManyIndirectObjects => error!( + Span::detached(), + "{prefix} the PDF has too many indirect objects"; + hint: "reduce the size of your document" + ), + // Can only occur if we have 27+ nested clip paths + ValidationError::TooHighQNestingLevel => error!( + Span::detached(), + "{prefix} the PDF has too high q nesting"; + hint: "reduce the number of nested containers" + ), + ValidationError::ContainsPostScript(loc) => error!( + to_span(*loc), + "{prefix} the PDF contains PostScript code"; + hint: "conic gradients are not supported in this PDF standard" + ), + ValidationError::MissingCMYKProfile => error!( + Span::detached(), + "{prefix} the PDF is missing a CMYK profile"; + hint: "CMYK colors are not yet supported in this export mode" + ), + ValidationError::ContainsNotDefGlyph(f, loc, text) => error!( + to_span(*loc), + "{prefix} the text '{text}' cannot be displayed using {}", + display_font(gc.fonts_backward.get(f).unwrap()); + hint: "try using a different font" + ), + ValidationError::InvalidCodepointMapping(_, _, cp, loc) => { + if let Some(c) = cp.map(|c| eco_format!("{:#06x}", c as u32)) { + let msg = if loc.is_some() { + "the PDF contains text with" + } else { + "the text contains" + }; + error!(to_span(*loc), "{prefix} {msg} the disallowed codepoint {c}") + } else { + // I think this code path is in theory unreachable, + // but just to be safe. + let msg = if loc.is_some() { + "the PDF contains text with missing codepoints" + } else { + "the text was not mapped to a code point" + }; + error!( + to_span(*loc), + "{prefix} {msg}"; + hint: "for complex scripts like Arabic, it might not be \ + possible to produce a compliant document" + ) + } + } + ValidationError::UnicodePrivateArea(_, _, c, loc) => { + let code_point = eco_format!("{:#06x}", *c as u32); + let msg = if loc.is_some() { "the PDF" } else { "the text" }; + error!( + to_span(*loc), + "{prefix} {msg} contains the codepoint {code_point}"; + hint: "codepoints from the Unicode private area are \ + forbidden in this export mode" + ) + } + ValidationError::Transparency(loc) => { + let span = to_span(*loc); + let hint1 = "try exporting with a different standard that \ + supports transparency"; + if loc.is_some() { + if gc.image_spans.contains(&span) { + error!( + span, "{prefix} the image contains transparency"; + hint: "{hint1}"; + hint: "or convert the image to a non-transparent one"; + hint: "you might have to convert SVGs into \ + non-transparent bitmap images" + ) + } else { + error!( + span, "{prefix} the used fill or stroke has transparency"; + hint: "{hint1}"; + hint: "or don't use colors with transparency in \ + this export mode" + ) + } + } else { + error!( + span, "{prefix} the PDF contains transparency"; + hint: "{hint1}" + ) + } + } + ValidationError::ImageInterpolation(loc) => { + let span = to_span(*loc); + if loc.is_some() { + error!( + span, "{prefix} the image has smooth scaling"; + hint: "set the `scaling` attribute to `pixelated`" + ) + } else { + error!( + span, "{prefix} an image in the PDF has smooth scaling"; + hint: "set the `scaling` attribute of all images to `pixelated`" + ) + } + } + ValidationError::EmbeddedFile(e, s) => { + // We always set the span for embedded files, so it cannot be detached. + let span = to_span(*s); + match e { + EmbedError::Existence => { + error!( + span, "{prefix} document contains an embedded file"; + hint: "embedded files are not supported in this export mode" + ) + } + EmbedError::MissingDate => { + error!( + span, "{prefix} document date is missing"; + hint: "the document must have a date when embedding files"; + hint: "`set document(date: none)` must not be used in this case" + ) + } + EmbedError::MissingDescription => { + error!(span, "{prefix} the file description is missing") + } + EmbedError::MissingMimeType => { + error!(span, "{prefix} the file mime type is missing") + } + } + } + // The below errors cannot occur yet, only once Typst supports full PDF/A + // and PDF/UA. But let's still add a message just to be on the safe side. + ValidationError::MissingAnnotationAltText => error!( + Span::detached(), + "{prefix} missing annotation alt text"; + hint: "please report this as a bug" + ), + ValidationError::MissingAltText => error!( + Span::detached(), + "{prefix} missing alt text"; + hint: "make sure your images and equations have alt text" + ), + ValidationError::NoDocumentLanguage => error!( + Span::detached(), + "{prefix} missing document language"; + hint: "set the language of the document" + ), + // Needs to be set by typst-pdf. + ValidationError::MissingHeadingTitle => error!( + Span::detached(), + "{prefix} missing heading title"; + hint: "please report this as a bug" + ), + ValidationError::MissingDocumentOutline => error!( + Span::detached(), + "{prefix} missing document outline"; + hint: "please report this as a bug" + ), + ValidationError::MissingTagging => error!( + Span::detached(), + "{prefix} missing document tags"; + hint: "please report this as a bug" + ), + ValidationError::NoDocumentTitle => error!( + Span::detached(), + "{prefix} missing document title"; + hint: "set the title of the document" + ), + ValidationError::MissingDocumentDate => error!( + Span::detached(), + "{prefix} missing document date"; + hint: "set the date of the document" + ), + } +} + +/// Convert a krilla location to a span. +fn to_span(loc: Option) -> Span { + loc.map(|l| Span::from_raw(NonZeroU64::new(l).unwrap())) + .unwrap_or(Span::detached()) +} + +fn collect_named_destinations( + document: &PagedDocument, + pic: &PageIndexConverter, +) -> HashMap { + let mut locs_to_names = HashMap::new(); + + // Find all headings that have a label and are the first among other + // headings with the same label. + let matches: Vec<_> = { + let mut seen = HashSet::new(); + document + .introspector + .query(&HeadingElem::elem().select()) + .iter() + .filter_map(|elem| elem.location().zip(elem.label())) + .filter(|&(_, label)| seen.insert(label)) + .collect() + }; + + for (loc, label) in matches { + let pos = document.introspector.position(loc); + let index = pos.page.get() - 1; + // We are subtracting 10 because the position of links e.g. to headings is always at the + // baseline and if you link directly to it, the text will not be visible + // because it is right above. + let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); + + // Only add named destination if page belonging to the position is exported. + if let Some(index) = pic.pdf_page_index(index) { + let named = NamedDestination::new( + label.resolve().to_string(), + XyzDestination::new( + index, + krilla::geom::Point::from_xy(pos.point.x.to_f32(), y.to_f32()), + ), + ); + locs_to_names.insert(loc, named); + } + } + + locs_to_names +} + +pub(crate) struct PageIndexConverter { + page_indices: HashMap, + skipped_pages: usize, +} + +impl PageIndexConverter { + pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { + let mut page_indices = HashMap::new(); + let mut skipped_pages = 0; + + for i in 0..document.pages.len() { + if options + .page_ranges + .as_ref() + .is_some_and(|ranges| !ranges.includes_page_index(i)) + { + skipped_pages += 1; + } else { + page_indices.insert(i, i - skipped_pages); + } + } + + Self { page_indices, skipped_pages } + } + + pub(crate) fn has_skipped_pages(&self) -> bool { + self.skipped_pages > 0 + } + + /// Get the PDF page index of a page index, if it's not excluded. + pub(crate) fn pdf_page_index(&self, page_index: usize) -> Option { + self.page_indices.get(&page_index).copied() + } +} diff --git a/crates/typst-pdf/src/embed.rs b/crates/typst-pdf/src/embed.rs index 597638f4b..6ed65a2b6 100644 --- a/crates/typst-pdf/src/embed.rs +++ b/crates/typst-pdf/src/embed.rs @@ -1,122 +1,54 @@ -use std::collections::BTreeMap; +use std::sync::Arc; -use ecow::EcoString; -use pdf_writer::types::AssociationKind; -use pdf_writer::{Filter, Finish, Name, Ref, Str, TextStr}; +use krilla::embed::{AssociationKind, EmbeddedFile}; +use krilla::Document; use typst_library::diag::{bail, SourceResult}; -use typst_library::foundations::{NativeElement, Packed, StyleChain}; +use typst_library::foundations::{NativeElement, StyleChain}; +use typst_library::layout::PagedDocument; use typst_library::pdf::{EmbedElem, EmbeddedFileRelationship}; -use crate::catalog::{document_date, pdf_date}; -use crate::{deflate, NameExt, PdfChunk, StrExt, WithGlobalRefs}; +pub(crate) fn embed_files( + typst_doc: &PagedDocument, + document: &mut Document, +) -> SourceResult<()> { + let elements = typst_doc.introspector.query(&EmbedElem::elem().select()); -/// Query for all [`EmbedElem`] and write them and their file specifications. -/// -/// This returns a map of embedding names and references so that we can later -/// add them to the catalog's `/Names` dictionary. -pub fn write_embedded_files( - ctx: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, BTreeMap)> { - let mut chunk = PdfChunk::new(); - let mut embedded_files = BTreeMap::default(); - - let elements = ctx.document.introspector.query(&EmbedElem::elem().select()); for elem in &elements { - if !ctx.options.standards.embedded_files { - // PDF/A-2 requires embedded files to be PDF/A-1 or PDF/A-2, - // which we don't currently check. - bail!( - elem.span(), - "file embeddings are not currently supported for PDF/A-2"; - hint: "PDF/A-3 supports arbitrary embedded files" - ); - } - let embed = elem.to_packed::().unwrap(); - if embed.path.derived.len() > Str::PDFA_LIMIT { - bail!(embed.span(), "embedded file path is too long"); - } - - let id = embed_file(ctx, &mut chunk, embed)?; - if embedded_files.insert(embed.path.derived.clone(), id).is_some() { - bail!( - elem.span(), - "duplicate embedded file for path `{}`", embed.path.derived; - hint: "embedded file paths must be unique", - ); - } - } - - Ok((chunk, embedded_files)) -} - -/// Write the embedded file stream and its file specification. -fn embed_file( - ctx: &WithGlobalRefs, - chunk: &mut PdfChunk, - embed: &Packed, -) -> SourceResult { - let embedded_file_stream_ref = chunk.alloc.bump(); - let file_spec_dict_ref = chunk.alloc.bump(); - - let data = embed.data.as_slice(); - let compressed = deflate(data); - - let mut embedded_file = chunk.embedded_file(embedded_file_stream_ref, &compressed); - embedded_file.filter(Filter::FlateDecode); - - if let Some(mime_type) = embed.mime_type(StyleChain::default()) { - if mime_type.len() > Name::PDFA_LIMIT { - bail!(embed.span(), "embedded file MIME type is too long"); - } - embedded_file.subtype(Name(mime_type.as_bytes())); - } else if ctx.options.standards.pdfa { - bail!(embed.span(), "embedded files must have a MIME type in PDF/A-3"); - } - - let mut params = embedded_file.params(); - params.size(data.len() as i32); - - let (date, tz) = document_date(ctx.document.info.date, ctx.options.timestamp); - if let Some(pdf_date) = date.and_then(|date| pdf_date(date, tz)) { - params.modification_date(pdf_date); - } else if ctx.options.standards.pdfa { - bail!( - embed.span(), - "the document must have a date when embedding files in PDF/A-3"; - hint: "`set document(date: none)` must not be used in this case" - ); - } - - params.finish(); - embedded_file.finish(); - - let mut file_spec = chunk.file_spec(file_spec_dict_ref); - file_spec.path(Str(embed.path.derived.as_bytes())); - file_spec.unic_file(TextStr(&embed.path.derived)); - file_spec - .insert(Name(b"EF")) - .dict() - .pair(Name(b"F"), embedded_file_stream_ref) - .pair(Name(b"UF"), embedded_file_stream_ref); - - if ctx.options.standards.pdfa { - // PDF 2.0, but ISO 19005-3 (PDF/A-3) Annex E allows it for PDF/A-3. - file_spec.association_kind(match embed.relationship(StyleChain::default()) { - Some(EmbeddedFileRelationship::Source) => AssociationKind::Source, - Some(EmbeddedFileRelationship::Data) => AssociationKind::Data, - Some(EmbeddedFileRelationship::Alternative) => AssociationKind::Alternative, - Some(EmbeddedFileRelationship::Supplement) => AssociationKind::Supplement, + let span = embed.span(); + let derived_path = &embed.path.derived; + let path = derived_path.to_string(); + let mime_type = + embed.mime_type(StyleChain::default()).clone().map(|s| s.to_string()); + let description = embed + .description(StyleChain::default()) + .clone() + .map(|s| s.to_string()); + let association_kind = match embed.relationship(StyleChain::default()) { None => AssociationKind::Unspecified, - }); - } + Some(e) => match e { + EmbeddedFileRelationship::Source => AssociationKind::Source, + EmbeddedFileRelationship::Data => AssociationKind::Data, + EmbeddedFileRelationship::Alternative => AssociationKind::Alternative, + EmbeddedFileRelationship::Supplement => AssociationKind::Supplement, + }, + }; + let data: Arc + Send + Sync> = Arc::new(embed.data.clone()); - if let Some(description) = embed.description(StyleChain::default()) { - if description.len() > Str::PDFA_LIMIT { - bail!(embed.span(), "embedded file description is too long"); + let file = EmbeddedFile { + path, + mime_type, + description, + association_kind, + data: data.into(), + compress: true, + location: Some(span.into_raw().get()), + }; + + if document.embed_file(file).is_none() { + bail!(span, "attempted to embed file {derived_path} twice"); } - file_spec.description(TextStr(description)); } - Ok(file_spec_dict_ref) + Ok(()) } diff --git a/crates/typst-pdf/src/extg.rs b/crates/typst-pdf/src/extg.rs deleted file mode 100644 index 06617d8d2..000000000 --- a/crates/typst-pdf/src/extg.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::collections::HashMap; - -use pdf_writer::Ref; -use typst_library::diag::SourceResult; - -use crate::{PdfChunk, WithGlobalRefs}; - -/// A PDF external graphics state. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -pub struct ExtGState { - // In the range 0-255, needs to be divided before being written into the graphics state! - pub stroke_opacity: u8, - // In the range 0-255, needs to be divided before being written into the graphics state! - pub fill_opacity: u8, -} - -impl Default for ExtGState { - fn default() -> Self { - Self { stroke_opacity: 255, fill_opacity: 255 } - } -} - -impl ExtGState { - pub fn uses_opacities(&self) -> bool { - self.stroke_opacity != 255 || self.fill_opacity != 255 - } -} - -/// Embed all used external graphics states into the PDF. -pub fn write_graphic_states( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for external_gs in resources.ext_gs.items() { - if out.contains_key(external_gs) { - continue; - } - - let id = chunk.alloc(); - out.insert(*external_gs, id); - chunk - .ext_graphics(id) - .non_stroking_alpha(external_gs.fill_opacity as f32 / 255.0) - .stroking_alpha(external_gs.stroke_opacity as f32 / 255.0); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} diff --git a/crates/typst-pdf/src/font.rs b/crates/typst-pdf/src/font.rs deleted file mode 100644 index f2df2ac92..000000000 --- a/crates/typst-pdf/src/font.rs +++ /dev/null @@ -1,278 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::hash::Hash; -use std::sync::Arc; - -use ecow::{eco_format, EcoString}; -use pdf_writer::types::{CidFontType, FontFlags, SystemInfo, UnicodeCmap}; -use pdf_writer::writers::{FontDescriptor, WMode}; -use pdf_writer::{Chunk, Filter, Finish, Name, Rect, Ref, Str}; -use subsetter::GlyphRemapper; -use ttf_parser::{name_id, GlyphId, Tag}; -use typst_library::diag::{At, SourceResult}; -use typst_library::text::Font; -use typst_syntax::Span; -use typst_utils::SliceExt; - -use crate::{deflate, EmExt, NameExt, PdfChunk, WithGlobalRefs}; - -const CFF: Tag = Tag::from_bytes(b"CFF "); -const CFF2: Tag = Tag::from_bytes(b"CFF2"); - -const SUBSET_TAG_LEN: usize = 6; -const IDENTITY_H: &str = "Identity-H"; - -pub(crate) const CMAP_NAME: Name = Name(b"Custom"); -pub(crate) const SYSTEM_INFO: SystemInfo = SystemInfo { - registry: Str(b"Adobe"), - ordering: Str(b"Identity"), - supplement: 0, -}; - -/// Embed all used fonts into the PDF. -#[typst_macros::time(name = "write fonts")] -pub fn write_fonts( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for font in resources.fonts.items() { - if out.contains_key(font) { - continue; - } - - let type0_ref = chunk.alloc(); - let cid_ref = chunk.alloc(); - let descriptor_ref = chunk.alloc(); - let cmap_ref = chunk.alloc(); - let data_ref = chunk.alloc(); - out.insert(font.clone(), type0_ref); - - let glyph_set = resources.glyph_sets.get(font).unwrap(); - let glyph_remapper = resources.glyph_remappers.get(font).unwrap(); - let ttf = font.ttf(); - - // Do we have a TrueType or CFF font? - // - // FIXME: CFF2 must be handled differently and requires PDF 2.0 - // (or we have to convert it to CFF). - let is_cff = ttf - .raw_face() - .table(CFF) - .or_else(|| ttf.raw_face().table(CFF2)) - .is_some(); - - let base_font = base_font_name(font, glyph_set); - let base_font_type0 = if is_cff { - eco_format!("{base_font}-{IDENTITY_H}") - } else { - base_font.clone() - }; - - // Write the base font object referencing the CID font. - chunk - .type0_font(type0_ref) - .base_font(Name(base_font_type0.as_bytes())) - .encoding_predefined(Name(IDENTITY_H.as_bytes())) - .descendant_font(cid_ref) - .to_unicode(cmap_ref); - - // Write the CID font referencing the font descriptor. - let mut cid = chunk.cid_font(cid_ref); - cid.subtype(if is_cff { CidFontType::Type0 } else { CidFontType::Type2 }); - cid.base_font(Name(base_font.as_bytes())); - cid.system_info(SYSTEM_INFO); - cid.font_descriptor(descriptor_ref); - cid.default_width(0.0); - if !is_cff { - cid.cid_to_gid_map_predefined(Name(b"Identity")); - } - - // Extract the widths of all glyphs. - // `remapped_gids` returns an iterator over the old GIDs in their new sorted - // order, so we can append the widths as is. - let widths = glyph_remapper - .remapped_gids() - .map(|gid| { - let width = ttf.glyph_hor_advance(GlyphId(gid)).unwrap_or(0); - font.to_em(width).to_font_units() - }) - .collect::>(); - - // Write all non-zero glyph widths. - let mut first = 0; - let mut width_writer = cid.widths(); - for (w, group) in widths.group_by_key(|&w| w) { - let end = first + group.len(); - if w != 0.0 { - let last = end - 1; - width_writer.same(first as u16, last as u16, w); - } - first = end; - } - - width_writer.finish(); - cid.finish(); - - // Write the /ToUnicode character map, which maps glyph ids back to - // unicode codepoints to enable copying out of the PDF. - let cmap = create_cmap(glyph_set, glyph_remapper); - chunk - .cmap(cmap_ref, &cmap) - .writing_mode(WMode::Horizontal) - .filter(Filter::FlateDecode); - - let subset = subset_font(font, glyph_remapper) - .map_err(|err| { - let postscript_name = font.find_name(name_id::POST_SCRIPT_NAME); - let name = postscript_name.as_deref().unwrap_or(&font.info().family); - eco_format!("failed to process font {name}: {err}") - }) - .at(Span::detached())?; - - let mut stream = chunk.stream(data_ref, &subset); - stream.filter(Filter::FlateDecode); - if is_cff { - stream.pair(Name(b"Subtype"), Name(b"CIDFontType0C")); - } - stream.finish(); - - let mut font_descriptor = - write_font_descriptor(&mut chunk, descriptor_ref, font, &base_font); - if is_cff { - font_descriptor.font_file3(data_ref); - } else { - font_descriptor.font_file2(data_ref); - } - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// Writes a FontDescriptor dictionary. -pub fn write_font_descriptor<'a>( - pdf: &'a mut Chunk, - descriptor_ref: Ref, - font: &'a Font, - base_font: &str, -) -> FontDescriptor<'a> { - let ttf = font.ttf(); - let metrics = font.metrics(); - let serif = font - .find_name(name_id::POST_SCRIPT_NAME) - .is_some_and(|name| name.contains("Serif")); - - let mut flags = FontFlags::empty(); - flags.set(FontFlags::SERIF, serif); - flags.set(FontFlags::FIXED_PITCH, ttf.is_monospaced()); - flags.set(FontFlags::ITALIC, ttf.is_italic()); - flags.insert(FontFlags::SYMBOLIC); - flags.insert(FontFlags::SMALL_CAP); - - let global_bbox = ttf.global_bounding_box(); - let bbox = Rect::new( - font.to_em(global_bbox.x_min).to_font_units(), - font.to_em(global_bbox.y_min).to_font_units(), - font.to_em(global_bbox.x_max).to_font_units(), - font.to_em(global_bbox.y_max).to_font_units(), - ); - - let italic_angle = ttf.italic_angle(); - let ascender = metrics.ascender.to_font_units(); - let descender = metrics.descender.to_font_units(); - let cap_height = metrics.cap_height.to_font_units(); - let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0); - - // Write the font descriptor (contains metrics about the font). - let mut font_descriptor = pdf.font_descriptor(descriptor_ref); - font_descriptor - .name(Name(base_font.as_bytes())) - .flags(flags) - .bbox(bbox) - .italic_angle(italic_angle) - .ascent(ascender) - .descent(descender) - .cap_height(cap_height) - .stem_v(stem_v); - - font_descriptor -} - -/// Subset a font to the given glyphs. -/// -/// - For a font with TrueType outlines, this produces the whole OpenType font. -/// - For a font with CFF outlines, this produces just the CFF font program. -/// -/// In both cases, this returns the already compressed data. -#[comemo::memoize] -#[typst_macros::time(name = "subset font")] -fn subset_font( - font: &Font, - glyph_remapper: &GlyphRemapper, -) -> Result>, subsetter::Error> { - let data = font.data(); - let subset = subsetter::subset(data, font.index(), glyph_remapper)?; - let mut data = subset.as_ref(); - - // Extract the standalone CFF font program if applicable. - let raw = ttf_parser::RawFace::parse(data, 0).unwrap(); - if let Some(cff) = raw.table(CFF) { - data = cff; - } - - Ok(Arc::new(deflate(data))) -} - -/// Creates the base font name for a font with a specific glyph subset. -/// Consists of a subset tag and the PostScript name of the font. -/// -/// Returns a string of length maximum 116, so that even with `-Identity-H` -/// added it does not exceed the maximum PDF/A name length of 127. -pub(crate) fn base_font_name(font: &Font, glyphs: &T) -> EcoString { - const MAX_LEN: usize = Name::PDFA_LIMIT - REST_LEN; - const REST_LEN: usize = SUBSET_TAG_LEN + 1 + 1 + IDENTITY_H.len(); - - let postscript_name = font.find_name(name_id::POST_SCRIPT_NAME); - let name = postscript_name.as_deref().unwrap_or("unknown"); - let trimmed = &name[..name.len().min(MAX_LEN)]; - - // Hash the full name (we might have trimmed) and the glyphs to produce - // a fairly unique subset tag. - let subset_tag = subset_tag(&(name, glyphs)); - - eco_format!("{subset_tag}+{trimmed}") -} - -/// Produce a unique 6 letter tag for a glyph set. -pub(crate) fn subset_tag(glyphs: &T) -> EcoString { - const BASE: u128 = 26; - let mut hash = typst_utils::hash128(&glyphs); - let mut letter = [b'A'; SUBSET_TAG_LEN]; - for l in letter.iter_mut() { - *l = b'A' + (hash % BASE) as u8; - hash /= BASE; - } - std::str::from_utf8(&letter).unwrap().into() -} - -/// Create a compressed `/ToUnicode` CMap. -#[comemo::memoize] -#[typst_macros::time(name = "create cmap")] -fn create_cmap( - glyph_set: &BTreeMap, - glyph_remapper: &GlyphRemapper, -) -> Arc> { - // Produce a reverse mapping from glyphs' CIDs to unicode strings. - let mut cmap = UnicodeCmap::new(CMAP_NAME, SYSTEM_INFO); - for (&g, text) in glyph_set.iter() { - // See commend in `write_normal_text` for why we can choose the CID this way. - let cid = glyph_remapper.get(g).unwrap(); - if !text.is_empty() { - cmap.pair_with_multiple(cid, text.chars()); - } - } - Arc::new(deflate(&cmap.finish())) -} diff --git a/crates/typst-pdf/src/gradient.rs b/crates/typst-pdf/src/gradient.rs deleted file mode 100644 index 6cd4c1ae8..000000000 --- a/crates/typst-pdf/src/gradient.rs +++ /dev/null @@ -1,512 +0,0 @@ -use std::collections::HashMap; -use std::f32::consts::{PI, TAU}; -use std::sync::Arc; - -use ecow::eco_format; -use pdf_writer::types::{ColorSpaceOperand, FunctionShadingType}; -use pdf_writer::writers::StreamShadingType; -use pdf_writer::{Filter, Finish, Name, Ref}; -use typst_library::diag::SourceResult; -use typst_library::layout::{Abs, Angle, Point, Quadrant, Ratio, Transform}; -use typst_library::visualize::{ - Color, ColorSpace, Gradient, RatioOrAngle, RelativeTo, WeightedColor, -}; -use typst_utils::Numeric; - -use crate::color::{ - self, check_cmyk_allowed, ColorSpaceExt, PaintEncode, QuantizedColor, -}; -use crate::{content, deflate, transform_to_array, AbsExt, PdfChunk, WithGlobalRefs}; - -/// A unique-transform-aspect-ratio combination that will be encoded into the -/// PDF. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct PdfGradient { - /// The transform to apply to the gradient. - pub transform: Transform, - /// The aspect ratio of the gradient. - /// Required for aspect ratio correction. - pub aspect_ratio: Ratio, - /// The gradient. - pub gradient: Gradient, - /// The corrected angle of the gradient. - pub angle: Angle, -} - -/// Writes the actual gradients (shading patterns) to the PDF. -/// This is performed once after writing all pages. -pub fn write_gradients( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for pdf_gradient in resources.gradients.items() { - if out.contains_key(pdf_gradient) { - continue; - } - - let shading = chunk.alloc(); - out.insert(pdf_gradient.clone(), shading); - - let PdfGradient { transform, aspect_ratio, gradient, angle } = pdf_gradient; - - let color_space = if gradient.space().hue_index().is_some() { - ColorSpace::Oklab - } else { - gradient.space() - }; - - if color_space == ColorSpace::Cmyk { - check_cmyk_allowed(context.options)?; - } - - let mut shading_pattern = match &gradient { - Gradient::Linear(_) => { - let shading_function = - shading_function(gradient, &mut chunk, color_space); - let mut shading_pattern = chunk.chunk.shading_pattern(shading); - let mut shading = shading_pattern.function_shading(); - shading.shading_type(FunctionShadingType::Axial); - - color::write( - color_space, - shading.color_space(), - &context.globals.color_functions, - ); - - let (mut sin, mut cos) = (angle.sin(), angle.cos()); - - // Scale to edges of unit square. - let factor = cos.abs() + sin.abs(); - sin *= factor; - cos *= factor; - - let (x1, y1, x2, y2): (f64, f64, f64, f64) = match angle.quadrant() { - Quadrant::First => (0.0, 0.0, cos, sin), - Quadrant::Second => (1.0, 0.0, cos + 1.0, sin), - Quadrant::Third => (1.0, 1.0, cos + 1.0, sin + 1.0), - Quadrant::Fourth => (0.0, 1.0, cos, sin + 1.0), - }; - - shading - .anti_alias(gradient.anti_alias()) - .function(shading_function) - .coords([x1 as f32, y1 as f32, x2 as f32, y2 as f32]) - .extend([true; 2]); - - shading.finish(); - - shading_pattern - } - Gradient::Radial(radial) => { - let shading_function = - shading_function(gradient, &mut chunk, color_space_of(gradient)); - let mut shading_pattern = chunk.chunk.shading_pattern(shading); - let mut shading = shading_pattern.function_shading(); - shading.shading_type(FunctionShadingType::Radial); - - color::write( - color_space, - shading.color_space(), - &context.globals.color_functions, - ); - - shading - .anti_alias(gradient.anti_alias()) - .function(shading_function) - .coords([ - radial.focal_center.x.get() as f32, - radial.focal_center.y.get() as f32, - radial.focal_radius.get() as f32, - radial.center.x.get() as f32, - radial.center.y.get() as f32, - radial.radius.get() as f32, - ]) - .extend([true; 2]); - - shading.finish(); - - shading_pattern - } - Gradient::Conic(_) => { - let vertices = compute_vertex_stream(gradient, *aspect_ratio); - - let stream_shading_id = chunk.alloc(); - let mut stream_shading = - chunk.chunk.stream_shading(stream_shading_id, &vertices); - - color::write( - color_space, - stream_shading.color_space(), - &context.globals.color_functions, - ); - - let range = color_space.range(); - stream_shading - .bits_per_coordinate(16) - .bits_per_component(16) - .bits_per_flag(8) - .shading_type(StreamShadingType::CoonsPatch) - .decode( - [0.0, 1.0, 0.0, 1.0].into_iter().chain(range.iter().copied()), - ) - .anti_alias(gradient.anti_alias()) - .filter(Filter::FlateDecode); - - stream_shading.finish(); - - let mut shading_pattern = chunk.shading_pattern(shading); - shading_pattern.shading_ref(stream_shading_id); - shading_pattern - } - }; - - shading_pattern.matrix(transform_to_array(*transform)); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// Writes an exponential or stitched function that expresses the gradient. -fn shading_function( - gradient: &Gradient, - chunk: &mut PdfChunk, - color_space: ColorSpace, -) -> Ref { - let function = chunk.alloc(); - let mut functions = vec![]; - let mut bounds = vec![]; - let mut encode = vec![]; - - // Create the individual gradient functions for each pair of stops. - for window in gradient.stops_ref().windows(2) { - let (first, second) = (window[0], window[1]); - - // If we have a hue index or are using Oklab, we will create several - // stops in-between to make the gradient smoother without interpolation - // issues with native color spaces. - let mut last_c = first.0; - if gradient.space().hue_index().is_some() { - for i in 0..=32 { - let t = i as f64 / 32.0; - let real_t = first.1.get() * (1.0 - t) + second.1.get() * t; - - let c = gradient.sample(RatioOrAngle::Ratio(Ratio::new(real_t))); - functions.push(single_gradient(chunk, last_c, c, color_space)); - bounds.push(real_t as f32); - encode.extend([0.0, 1.0]); - last_c = c; - } - } - - bounds.push(second.1.get() as f32); - functions.push(single_gradient(chunk, first.0, second.0, color_space)); - encode.extend([0.0, 1.0]); - } - - // Special case for gradients with only two stops. - if functions.len() == 1 { - return functions[0]; - } - - // Remove the last bound, since it's not needed for the stitching function. - bounds.pop(); - - // Create the stitching function. - chunk - .stitching_function(function) - .domain([0.0, 1.0]) - .range(color_space.range().iter().copied()) - .functions(functions) - .bounds(bounds) - .encode(encode); - - function -} - -/// Writes an exponential function that expresses a single segment (between two -/// stops) of a gradient. -fn single_gradient( - chunk: &mut PdfChunk, - first_color: Color, - second_color: Color, - color_space: ColorSpace, -) -> Ref { - let reference = chunk.alloc(); - chunk - .exponential_function(reference) - .range(color_space.range().iter().copied()) - .c0(color_space.convert(first_color)) - .c1(color_space.convert(second_color)) - .domain([0.0, 1.0]) - .n(1.0); - - reference -} - -impl PaintEncode for Gradient { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_fill_color_space(); - - let index = register_gradient(ctx, self, on_text, transforms); - let id = eco_format!("Gr{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_fill_pattern(None, name); - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_stroke_color_space(); - - let index = register_gradient(ctx, self, on_text, transforms); - let id = eco_format!("Gr{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_stroke_pattern(None, name); - Ok(()) - } -} - -/// Deduplicates a gradient to a named PDF resource. -fn register_gradient( - ctx: &mut content::Builder, - gradient: &Gradient, - on_text: bool, - mut transforms: content::Transforms, -) -> usize { - // Edge cases for strokes. - if transforms.size.x.is_zero() { - transforms.size.x = Abs::pt(1.0); - } - - if transforms.size.y.is_zero() { - transforms.size.y = Abs::pt(1.0); - } - let size = match gradient.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.size, - RelativeTo::Parent => transforms.container_size, - }; - - let (offset_x, offset_y) = match gradient { - Gradient::Conic(conic) => ( - -size.x * (1.0 - conic.center.x.get() / 2.0) / 2.0, - -size.y * (1.0 - conic.center.y.get() / 2.0) / 2.0, - ), - _ => (Abs::zero(), Abs::zero()), - }; - - let rotation = gradient.angle().unwrap_or_else(Angle::zero); - - let transform = match gradient.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.transform, - RelativeTo::Parent => transforms.container_transform, - }; - - let scale_offset = match gradient { - Gradient::Conic(_) => 4.0_f64, - _ => 1.0, - }; - - let pdf_gradient = PdfGradient { - aspect_ratio: size.aspect_ratio(), - transform: transform - .pre_concat(Transform::translate( - offset_x * scale_offset, - offset_y * scale_offset, - )) - .pre_concat(Transform::scale( - Ratio::new(size.x.to_pt() * scale_offset), - Ratio::new(size.y.to_pt() * scale_offset), - )), - gradient: gradient.clone(), - angle: Gradient::correct_aspect_ratio(rotation, size.aspect_ratio()), - }; - - ctx.resources.colors.mark_as_used(color_space_of(gradient)); - - ctx.resources.gradients.insert(pdf_gradient) -} - -/// Writes a single Coons Patch as defined in the PDF specification -/// to a binary vec. -/// -/// Structure: -/// - flag: `u8` -/// - points: `[u16; 24]` -/// - colors: `[u16; 4*N]` (N = number of components) -fn write_patch( - target: &mut Vec, - t: f32, - t1: f32, - c0: &[u16], - c1: &[u16], - angle: Angle, -) { - let theta = -TAU * t + angle.to_rad() as f32 + PI; - let theta1 = -TAU * t1 + angle.to_rad() as f32 + PI; - - let (cp1, cp2) = - control_point(Point::new(Abs::pt(0.5), Abs::pt(0.5)), 0.5, theta, theta1); - - // Push the flag - target.push(0); - - let p1 = - [u16::quantize(0.5, [0.0, 1.0]).to_be(), u16::quantize(0.5, [0.0, 1.0]).to_be()]; - - let p2 = [ - u16::quantize(theta.cos(), [-1.0, 1.0]).to_be(), - u16::quantize(theta.sin(), [-1.0, 1.0]).to_be(), - ]; - - let p3 = [ - u16::quantize(theta1.cos(), [-1.0, 1.0]).to_be(), - u16::quantize(theta1.sin(), [-1.0, 1.0]).to_be(), - ]; - - let cp1 = [ - u16::quantize(cp1.x.to_f32(), [0.0, 1.0]).to_be(), - u16::quantize(cp1.y.to_f32(), [0.0, 1.0]).to_be(), - ]; - - let cp2 = [ - u16::quantize(cp2.x.to_f32(), [0.0, 1.0]).to_be(), - u16::quantize(cp2.y.to_f32(), [0.0, 1.0]).to_be(), - ]; - - // Push the points - target.extend_from_slice(bytemuck::cast_slice(&[ - p1, p1, p2, p2, cp1, cp2, p3, p3, p1, p1, p1, p1, - ])); - - // Push the colors. - let colors = [c0, c0, c1, c1] - .into_iter() - .flat_map(|c| c.iter().copied().map(u16::to_be_bytes)) - .flatten(); - - target.extend(colors); -} - -fn control_point(c: Point, r: f32, angle_start: f32, angle_end: f32) -> (Point, Point) { - let n = (TAU / (angle_end - angle_start)).abs(); - let f = ((angle_end - angle_start) / n).tan() * 4.0 / 3.0; - - let p1 = c + Point::new( - Abs::pt((r * angle_start.cos() - f * r * angle_start.sin()) as f64), - Abs::pt((r * angle_start.sin() + f * r * angle_start.cos()) as f64), - ); - - let p2 = c + Point::new( - Abs::pt((r * angle_end.cos() + f * r * angle_end.sin()) as f64), - Abs::pt((r * angle_end.sin() - f * r * angle_end.cos()) as f64), - ); - - (p1, p2) -} - -#[comemo::memoize] -fn compute_vertex_stream(gradient: &Gradient, aspect_ratio: Ratio) -> Arc> { - let Gradient::Conic(conic) = gradient else { unreachable!() }; - - // Generated vertices for the Coons patches - let mut vertices = Vec::new(); - - // Correct the gradient's angle - let angle = Gradient::correct_aspect_ratio(conic.angle, aspect_ratio); - - for window in conic.stops.windows(2) { - let ((c0, t0), (c1, t1)) = (window[0], window[1]); - - // Precision: - // - On an even color, insert a stop every 90deg - // - For a hue-based color space, insert 200 stops minimum - // - On any other, insert 20 stops minimum - let max_dt = if c0 == c1 { - 0.25 - } else if conic.space.hue_index().is_some() { - 0.005 - } else { - 0.05 - }; - let encode_space = conic - .space - .hue_index() - .map(|_| ColorSpace::Oklab) - .unwrap_or(conic.space); - let mut t_x = t0.get(); - let dt = (t1.get() - t0.get()).min(max_dt); - - // Special casing for sharp gradients. - if t0 == t1 { - write_patch( - &mut vertices, - t0.get() as f32, - t1.get() as f32, - &encode_space.convert(c0), - &encode_space.convert(c1), - angle, - ); - continue; - } - - while t_x < t1.get() { - let t_next = (t_x + dt).min(t1.get()); - - // The current progress in the current window. - let t = |t| (t - t0.get()) / (t1.get() - t0.get()); - let c = Color::mix_iter( - [WeightedColor::new(c0, 1.0 - t(t_x)), WeightedColor::new(c1, t(t_x))], - conic.space, - ) - .unwrap(); - - let c_next = Color::mix_iter( - [ - WeightedColor::new(c0, 1.0 - t(t_next)), - WeightedColor::new(c1, t(t_next)), - ], - conic.space, - ) - .unwrap(); - - write_patch( - &mut vertices, - t_x as f32, - t_next as f32, - &encode_space.convert(c), - &encode_space.convert(c_next), - angle, - ); - - t_x = t_next; - } - } - - Arc::new(deflate(&vertices)) -} - -fn color_space_of(gradient: &Gradient) -> ColorSpace { - if gradient.space().hue_index().is_some() { - ColorSpace::Oklab - } else { - gradient.space() - } -} diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index fa326e3e0..93bdb1950 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -1,249 +1,244 @@ -use std::collections::HashMap; -use std::io::Cursor; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, OnceLock}; -use ecow::eco_format; -use image::{DynamicImage, GenericImageView, Rgba}; -use pdf_writer::{Chunk, Filter, Finish, Ref}; -use typst_library::diag::{At, SourceResult, StrResult}; +use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba}; +use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; +use krilla::surface::Surface; +use krilla_svg::{SurfaceExt, SvgSettings}; +use typst_library::diag::{bail, SourceResult}; use typst_library::foundations::Smart; +use typst_library::layout::{Abs, Angle, Ratio, Size, Transform}; use typst_library::visualize::{ - ColorSpace, ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, - RasterImage, SvgImage, + ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, RasterImage, }; -use typst_utils::Deferred; +use typst_syntax::Span; -use crate::{color, deflate, PdfChunk, WithGlobalRefs}; +use crate::convert::{FrameContext, GlobalContext}; +use crate::util::{SizeExt, TransformExt}; -/// Embed all used images into the PDF. -#[typst_macros::time(name = "write images")] -pub fn write_images( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for (i, image) in resources.images.items().enumerate() { - if out.contains_key(image) { - continue; +#[typst_macros::time(name = "handle image")] +pub(crate) fn handle_image( + gc: &mut GlobalContext, + fc: &mut FrameContext, + image: &Image, + size: Size, + surface: &mut Surface, + span: Span, +) -> SourceResult<()> { + surface.push_transform(&fc.state().transform().to_krilla()); + surface.set_location(span.into_raw().get()); + + let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth); + + if let Some(alt) = image.alt() { + surface.start_alt_text(alt); + } + + gc.image_spans.insert(span); + + match image.kind() { + ImageKind::Raster(raster) => { + let (exif_transform, new_size) = exif_transform(raster, size); + surface.push_transform(&exif_transform.to_krilla()); + + let image = match convert_raster(raster.clone(), interpolate) { + None => bail!(span, "failed to process image"), + Some(i) => i, + }; + + if !gc.image_to_spans.contains_key(&image) { + gc.image_to_spans.insert(image.clone(), span); } - let (handle, span) = resources.deferred_images.get(&i).unwrap(); - let encoded = handle.wait().as_ref().map_err(Clone::clone).at(*span)?; - - match encoded { - EncodedImage::Raster { - data, - filter, - color_space, - bits_per_component, - width, - height, - compressed_icc, - alpha, - interpolate, - } => { - let image_ref = chunk.alloc(); - out.insert(image.clone(), image_ref); - - let mut image = chunk.chunk.image_xobject(image_ref, data); - image.filter(*filter); - image.width(*width as i32); - image.height(*height as i32); - image.bits_per_component(i32::from(*bits_per_component)); - image.interpolate(*interpolate); - - let mut icc_ref = None; - let space = image.color_space(); - if compressed_icc.is_some() { - let id = chunk.alloc.bump(); - space.icc_based(id); - icc_ref = Some(id); - } else { - color::write( - *color_space, - space, - &context.globals.color_functions, - ); - } - - // Add a second gray-scale image containing the alpha values if - // this image has an alpha channel. - if let Some((alpha_data, alpha_filter)) = alpha { - let mask_ref = chunk.alloc.bump(); - image.s_mask(mask_ref); - image.finish(); - - let mut mask = chunk.image_xobject(mask_ref, alpha_data); - mask.filter(*alpha_filter); - mask.width(*width as i32); - mask.height(*height as i32); - mask.color_space().device_gray(); - mask.bits_per_component(i32::from(*bits_per_component)); - mask.interpolate(*interpolate); - } else { - image.finish(); - } - - if let (Some(compressed_icc), Some(icc_ref)) = - (compressed_icc, icc_ref) - { - let mut stream = chunk.icc_profile(icc_ref, compressed_icc); - stream.filter(Filter::FlateDecode); - match color_space { - ColorSpace::Srgb => { - stream.n(3); - stream.alternate().srgb(); - } - ColorSpace::D65Gray => { - stream.n(1); - stream.alternate().d65_gray(); - } - _ => unimplemented!(), - } - } - } - EncodedImage::Svg(svg_chunk, id) => { - let mut map = HashMap::new(); - svg_chunk.renumber_into(&mut chunk.chunk, |old| { - *map.entry(old).or_insert_with(|| chunk.alloc.bump()) - }); - out.insert(image.clone(), map[id]); - } - } + surface.draw_image(image, new_size.to_krilla()); + surface.pop(); } + ImageKind::Svg(svg) => { + surface.draw_svg( + svg.tree(), + size.to_krilla(), + SvgSettings { embed_text: true, ..Default::default() }, + ); + } + } - Ok(()) - })?; + if image.alt().is_some() { + surface.end_alt_text(); + } - Ok((chunk, out)) + surface.pop(); + surface.reset_location(); + + Ok(()) } -/// Creates a new PDF image from the given image. -/// -/// Also starts the deferred encoding of the image. -#[comemo::memoize] -pub fn deferred_image( - image: Image, - pdfa: bool, -) -> (Deferred>, Option) { - let color_space = match image.kind() { - ImageKind::Raster(raster) if raster.icc().is_none() => { - Some(to_color_space(raster.dynamic().color())) +struct Repr { + /// The original, underlying raster image. + raster: RasterImage, + /// The alpha channel of the raster image, if existing. + alpha_channel: OnceLock>>, + /// A (potentially) converted version of the dynamic image stored `raster` that is + /// guaranteed to either be in luma8 or rgb8, and thus can be used for the + /// `color_channel` method of `CustomImage`. + actual_dynamic: OnceLock>, +} + +/// A wrapper around `RasterImage` so that we can implement `CustomImage`. +#[derive(Clone)] +struct PdfImage(Arc); + +impl PdfImage { + pub fn new(raster: RasterImage) -> Self { + Self(Arc::new(Repr { + raster, + alpha_channel: OnceLock::new(), + actual_dynamic: OnceLock::new(), + })) + } +} + +impl Hash for PdfImage { + fn hash(&self, state: &mut H) { + // `alpha_channel` and `actual_dynamic` are generated from the underlying `RasterImage`, + // so this is enough. Since `raster` is prehashed, this is also very cheap. + self.0.raster.hash(state); + } +} + +impl CustomImage for PdfImage { + fn color_channel(&self) -> &[u8] { + self.0 + .actual_dynamic + .get_or_init(|| { + let dynamic = self.0.raster.dynamic(); + let channel_count = dynamic.color().channel_count(); + + match (dynamic.as_ref(), channel_count) { + // Pure luma8 or rgb8 image, can use it directly. + (DynamicImage::ImageLuma8(_), _) => dynamic.clone(), + (DynamicImage::ImageRgb8(_), _) => dynamic.clone(), + // Grey-scale image, convert to luma8. + (_, 1 | 2) => Arc::new(DynamicImage::ImageLuma8(dynamic.to_luma8())), + // Anything else, convert to rgb8. + _ => Arc::new(DynamicImage::ImageRgb8(dynamic.to_rgb8())), + } + }) + .as_bytes() + } + + fn alpha_channel(&self) -> Option<&[u8]> { + self.0 + .alpha_channel + .get_or_init(|| { + self.0.raster.dynamic().color().has_alpha().then(|| { + self.0 + .raster + .dynamic() + .pixels() + .map(|(_, _, Rgba([_, _, _, a]))| a) + .collect() + }) + }) + .as_ref() + .map(|v| &**v) + } + + fn bits_per_component(&self) -> BitsPerComponent { + BitsPerComponent::Eight + } + + fn size(&self) -> (u32, u32) { + (self.0.raster.width(), self.0.raster.height()) + } + + fn icc_profile(&self) -> Option<&[u8]> { + if matches!( + self.0.raster.dynamic().as_ref(), + DynamicImage::ImageLuma8(_) + | DynamicImage::ImageLumaA8(_) + | DynamicImage::ImageRgb8(_) + | DynamicImage::ImageRgba8(_) + ) { + self.0.raster.icc().map(|b| b.as_bytes()) + } else { + // In all other cases, the dynamic will be converted into RGB8 or LUMA8, so the ICC + // profile may become invalid, and thus we don't include it. + None } - _ => None, + } + + fn color_space(&self) -> ImageColorspace { + // Remember that we convert all images to either RGB or luma. + if self.0.raster.dynamic().color().has_color() { + ImageColorspace::Rgb + } else { + ImageColorspace::Luma + } + } +} + +#[comemo::memoize] +fn convert_raster( + raster: RasterImage, + interpolate: bool, +) -> Option { + if let RasterFormat::Exchange(ExchangeFormat::Jpg) = raster.format() { + let image_data: Arc + Send + Sync> = + Arc::new(raster.data().clone()); + let icc_profile = raster.icc().map(|i| { + let i: Arc + Send + Sync> = Arc::new(i.clone()); + i + }); + + krilla::image::Image::from_jpeg_with_icc( + image_data.into(), + icc_profile.map(|i| i.into()), + interpolate, + ) + } else { + krilla::image::Image::from_custom(PdfImage::new(raster), interpolate) + } +} + +fn exif_transform(image: &RasterImage, size: Size) -> (Transform, Size) { + let base = |hp: bool, vp: bool, mut base_ts: Transform, size: Size| { + if hp { + // Flip horizontally in-place. + base_ts = base_ts.pre_concat( + Transform::scale(-Ratio::one(), Ratio::one()) + .pre_concat(Transform::translate(-size.x, Abs::zero())), + ) + } + + if vp { + // Flip vertically in-place. + base_ts = base_ts.pre_concat( + Transform::scale(Ratio::one(), -Ratio::one()) + .pre_concat(Transform::translate(Abs::zero(), -size.y)), + ) + } + + base_ts }; - // PDF/A does not appear to allow interpolation. - // See https://github.com/typst/typst/issues/2942. - let interpolate = !pdfa && image.scaling() == Smart::Custom(ImageScaling::Smooth); + let no_flipping = + |hp: bool, vp: bool| (base(hp, vp, Transform::identity(), size), size); - let deferred = Deferred::new(move || match image.kind() { - ImageKind::Raster(raster) => Ok(encode_raster_image(raster, interpolate)), - ImageKind::Svg(svg) => { - let (chunk, id) = encode_svg(svg, pdfa) - .map_err(|err| eco_format!("failed to convert SVG to PDF: {err}"))?; - Ok(EncodedImage::Svg(chunk, id)) - } - }); + let with_flipping = |hp: bool, vp: bool| { + let base_ts = Transform::rotate_at(Angle::deg(90.0), Abs::zero(), Abs::zero()) + .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())); + let inv_size = Size::new(size.y, size.x); + (base(hp, vp, base_ts, inv_size), inv_size) + }; - (deferred, color_space) -} - -/// Encode an image with a suitable filter. -#[typst_macros::time(name = "encode raster image")] -fn encode_raster_image(image: &RasterImage, interpolate: bool) -> EncodedImage { - let dynamic = image.dynamic(); - let color_space = to_color_space(dynamic.color()); - - let (filter, data, bits_per_component) = - if image.format() == RasterFormat::Exchange(ExchangeFormat::Jpg) { - let mut data = Cursor::new(vec![]); - dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap(); - (Filter::DctDecode, data.into_inner(), 8) - } else { - // TODO: Encode flate streams with PNG-predictor? - let (data, bits_per_component) = match (dynamic, color_space) { - // RGB image. - (DynamicImage::ImageRgb8(rgb), _) => (deflate(rgb.as_raw()), 8), - // Grayscale image - (DynamicImage::ImageLuma8(luma), _) => (deflate(luma.as_raw()), 8), - (_, ColorSpace::D65Gray) => (deflate(dynamic.to_luma8().as_raw()), 8), - // Anything else - _ => (deflate(dynamic.to_rgb8().as_raw()), 8), - }; - (Filter::FlateDecode, data, bits_per_component) - }; - - let compressed_icc = image.icc().map(|data| deflate(data)); - let alpha = dynamic.color().has_alpha().then(|| encode_alpha(dynamic)); - - EncodedImage::Raster { - data, - filter, - color_space, - bits_per_component, - width: image.width(), - height: image.height(), - compressed_icc, - alpha, - interpolate, - } -} - -/// Encode an image's alpha channel if present. -#[typst_macros::time(name = "encode alpha")] -fn encode_alpha(image: &DynamicImage) -> (Vec, Filter) { - let pixels: Vec<_> = image.pixels().map(|(_, _, Rgba([_, _, _, a]))| a).collect(); - (deflate(&pixels), Filter::FlateDecode) -} - -/// Encode an SVG into a chunk of PDF objects. -#[typst_macros::time(name = "encode svg")] -fn encode_svg( - svg: &SvgImage, - pdfa: bool, -) -> Result<(Chunk, Ref), svg2pdf::ConversionError> { - svg2pdf::to_chunk( - svg.tree(), - svg2pdf::ConversionOptions { pdfa, ..Default::default() }, - ) -} - -/// A pre-encoded image. -pub enum EncodedImage { - /// A pre-encoded rasterized image. - Raster { - /// The raw, pre-deflated image data. - data: Vec, - /// The filter to use for the image. - filter: Filter, - /// Which color space this image is encoded in. - color_space: ColorSpace, - /// How many bits of each color component are stored. - bits_per_component: u8, - /// The image's width. - width: u32, - /// The image's height. - height: u32, - /// The image's ICC profile, deflated, if any. - compressed_icc: Option>, - /// The alpha channel of the image, pre-deflated, if any. - alpha: Option<(Vec, Filter)>, - /// Whether image interpolation should be enabled. - interpolate: bool, - }, - /// A vector graphic. - /// - /// The chunk is the SVG converted to PDF objects. - Svg(Chunk, Ref), -} - -/// Matches an [`image::ColorType`] to [`ColorSpace`]. -fn to_color_space(color: image::ColorType) -> ColorSpace { - use image::ColorType::*; - match color { - L8 | La8 | L16 | La16 => ColorSpace::D65Gray, - Rgb8 | Rgba8 | Rgb16 | Rgba16 | Rgb32F | Rgba32F => ColorSpace::Srgb, - _ => unimplemented!(), + match image.exif_rotation() { + Some(2) => no_flipping(true, false), + Some(3) => no_flipping(true, true), + Some(4) => no_flipping(false, true), + Some(5) => with_flipping(false, false), + Some(6) => with_flipping(true, false), + Some(7) => with_flipping(true, true), + Some(8) => with_flipping(false, true), + _ => no_flipping(false, false), } } diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 88e62389c..4e0b74308 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -1,81 +1,33 @@ -//! Exporting of Typst documents into PDFs. +//! Exporting Typst documents to PDF. -mod catalog; -mod color; -mod color_font; -mod content; +mod convert; mod embed; -mod extg; -mod font; -mod gradient; mod image; -mod named_destination; +mod link; +mod metadata; mod outline; mod page; -mod resources; -mod tiling; +mod paint; +mod shape; +mod text; +mod util; + +pub use self::metadata::{Timestamp, Timezone}; -use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Debug, Formatter}; -use std::hash::Hash; -use std::ops::{Deref, DerefMut}; -use base64::Engine; -use ecow::EcoString; -use pdf_writer::{Chunk, Name, Pdf, Ref, Str, TextStr}; +use ecow::eco_format; use serde::{Deserialize, Serialize}; use typst_library::diag::{bail, SourceResult, StrResult}; -use typst_library::foundations::{Datetime, Smart}; -use typst_library::layout::{Abs, Em, PageRanges, PagedDocument, Transform}; -use typst_library::text::Font; -use typst_library::visualize::Image; -use typst_syntax::Span; -use typst_utils::Deferred; - -use crate::catalog::write_catalog; -use crate::color::{alloc_color_functions_refs, ColorFunctionRefs}; -use crate::color_font::{write_color_fonts, ColorFontSlice}; -use crate::embed::write_embedded_files; -use crate::extg::{write_graphic_states, ExtGState}; -use crate::font::write_fonts; -use crate::gradient::{write_gradients, PdfGradient}; -use crate::image::write_images; -use crate::named_destination::{write_named_destinations, NamedDestinations}; -use crate::page::{alloc_page_refs, traverse_pages, write_page_tree, EncodedPage}; -use crate::resources::{ - alloc_resources_refs, write_resource_dictionaries, Resources, ResourcesRefs, -}; -use crate::tiling::{write_tilings, PdfTiling}; +use typst_library::foundations::Smart; +use typst_library::layout::{PageRanges, PagedDocument}; /// Export a document into a PDF file. /// /// Returns the raw bytes making up the PDF file. #[typst_macros::time(name = "pdf")] pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult> { - PdfBuilder::new(document, options) - .phase(|builder| builder.run(traverse_pages))? - .phase(|builder| { - Ok(GlobalRefs { - color_functions: builder.run(alloc_color_functions_refs)?, - pages: builder.run(alloc_page_refs)?, - resources: builder.run(alloc_resources_refs)?, - }) - })? - .phase(|builder| { - Ok(References { - named_destinations: builder.run(write_named_destinations)?, - fonts: builder.run(write_fonts)?, - color_fonts: builder.run(write_color_fonts)?, - images: builder.run(write_images)?, - gradients: builder.run(write_gradients)?, - tilings: builder.run(write_tilings)?, - ext_gs: builder.run(write_graphic_states)?, - embedded_files: builder.run(write_embedded_files)?, - }) - })? - .phase(|builder| builder.run(write_page_tree))? - .phase(|builder| builder.run(write_resource_dictionaries))? - .export_with(write_catalog) + convert::convert(document, options) } /// Settings for PDF export. @@ -103,82 +55,74 @@ pub struct PdfOptions<'a> { pub standards: PdfStandards, } -/// A timestamp with timezone information. -#[derive(Debug, Clone, Copy)] -pub struct Timestamp { - /// The datetime of the timestamp. - pub(crate) datetime: Datetime, - /// The timezone of the timestamp. - pub(crate) timezone: Timezone, -} - -impl Timestamp { - /// Create a new timestamp with a given datetime and UTC suffix. - pub fn new_utc(datetime: Datetime) -> Self { - Self { datetime, timezone: Timezone::UTC } - } - - /// Create a new timestamp with a given datetime, and a local timezone offset. - pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option { - let hour_offset = (whole_minute_offset / 60).try_into().ok()?; - // Note: the `%` operator in Rust is the remainder operator, not the - // modulo operator. The remainder operator can return negative results. - // We can simply apply `abs` here because we assume the `minute_offset` - // will have the same sign as `hour_offset`. - let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?; - match (hour_offset, minute_offset) { - // Only accept valid timezone offsets with `-23 <= hours <= 23`, - // and `0 <= minutes <= 59`. - (-23..=23, 0..=59) => Some(Self { - datetime, - timezone: Timezone::Local { hour_offset, minute_offset }, - }), - _ => None, - } - } -} - -/// A timezone. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Timezone { - /// The UTC timezone. - UTC, - /// The local timezone offset from UTC. And the `minute_offset` will have - /// same sign as `hour_offset`. - Local { hour_offset: i8, minute_offset: u8 }, -} - /// Encapsulates a list of compatible PDF standards. #[derive(Clone)] pub struct PdfStandards { - /// For now, we simplify to just PDF/A. But it can be more fine-grained in - /// the future. - pub(crate) pdfa: bool, - /// Whether the standard allows for embedding any kind of file into the PDF. - /// We disallow this for PDF/A-2, since it only allows embedding - /// PDF/A-1 and PDF/A-2 documents. - pub(crate) embedded_files: bool, - /// Part of the PDF/A standard. - pub(crate) pdfa_part: Option<(i32, &'static str)>, + pub(crate) config: krilla::configure::Configuration, } impl PdfStandards { /// Validates a list of PDF standards for compatibility and returns their /// encapsulated representation. pub fn new(list: &[PdfStandard]) -> StrResult { - let a2b = list.contains(&PdfStandard::A_2b); - let a3b = list.contains(&PdfStandard::A_3b); + use krilla::configure::{Configuration, PdfVersion, Validator}; - if a2b && a3b { - bail!("PDF cannot conform to A-2B and A-3B at the same time") + let mut version: Option = None; + let mut set_version = |v: PdfVersion| -> StrResult<()> { + if let Some(prev) = version { + bail!( + "PDF cannot conform to {} and {} at the same time", + prev.as_str(), + v.as_str() + ); + } + version = Some(v); + Ok(()) + }; + + let mut validator = None; + let mut set_validator = |v: Validator| -> StrResult<()> { + if validator.is_some() { + bail!("Typst currently only supports one PDF substandard at a time"); + } + validator = Some(v); + Ok(()) + }; + + for standard in list { + match standard { + PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, + PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, + PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, + PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, + PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, + PdfStandard::A_1b => set_validator(Validator::A1_B)?, + PdfStandard::A_2b => set_validator(Validator::A2_B)?, + PdfStandard::A_2u => set_validator(Validator::A2_U)?, + PdfStandard::A_3b => set_validator(Validator::A3_B)?, + PdfStandard::A_3u => set_validator(Validator::A3_U)?, + PdfStandard::A_4 => set_validator(Validator::A4)?, + PdfStandard::A_4f => set_validator(Validator::A4F)?, + PdfStandard::A_4e => set_validator(Validator::A4E)?, + } } - let pdfa = a2b || a3b; - Ok(Self { - pdfa, - embedded_files: !a2b, - pdfa_part: pdfa.then_some((if a2b { 2 } else { 3 }, "B")), - }) + let config = match (version, validator) { + (Some(version), Some(validator)) => { + Configuration::new_with(validator, version).ok_or_else(|| { + eco_format!( + "{} is not compatible with {}", + version.as_str(), + validator.as_str() + ) + })? + } + (Some(version), None) => Configuration::new_with_version(version), + (None, Some(validator)) => Configuration::new_with_validator(validator), + (None, None) => Configuration::new_with_version(PdfVersion::Pdf17), + }; + + Ok(Self { config }) } } @@ -190,7 +134,10 @@ impl Debug for PdfStandards { impl Default for PdfStandards { fn default() -> Self { - Self { pdfa: false, embedded_files: true, pdfa_part: None } + use krilla::configure::{Configuration, PdfVersion}; + Self { + config: Configuration::new_with_version(PdfVersion::Pdf17), + } } } @@ -201,531 +148,43 @@ impl Default for PdfStandards { #[allow(non_camel_case_types)] #[non_exhaustive] pub enum PdfStandard { + /// PDF 1.4. + #[serde(rename = "1.4")] + V_1_4, + /// PDF 1.5. + #[serde(rename = "1.5")] + V_1_5, + /// PDF 1.5. + #[serde(rename = "1.6")] + V_1_6, /// PDF 1.7. #[serde(rename = "1.7")] V_1_7, + /// PDF 2.0. + #[serde(rename = "2.0")] + V_2_0, + /// PDF/A-1b. + #[serde(rename = "a-1b")] + A_1b, /// PDF/A-2b. #[serde(rename = "a-2b")] A_2b, - /// PDF/A-3b. + /// PDF/A-2u. + #[serde(rename = "a-2u")] + A_2u, + /// PDF/A-3u. #[serde(rename = "a-3b")] A_3b, -} - -/// A struct to build a PDF following a fixed succession of phases. -/// -/// This type uses generics to represent its current state. `S` (for "state") is -/// all data that was produced by the previous phases, that is now read-only. -/// -/// Phase after phase, this state will be transformed. Each phase corresponds to -/// a call to the [eponymous function](`PdfBuilder::phase`) and produces a new -/// part of the state, that will be aggregated with all other information, for -/// consumption during the next phase. -/// -/// In other words: this struct follows the **typestate pattern**. This prevents -/// you from using data that is not yet available, at the type level. -/// -/// Each phase consists of processes, that can read the state of the previous -/// phases, and construct a part of the new state. -/// -/// A final step, that has direct access to the global reference allocator and -/// PDF document, can be run with [`PdfBuilder::export_with`]. -struct PdfBuilder { - /// The context that has been accumulated so far. - state: S, - /// A global bump allocator. - alloc: Ref, - /// The PDF document that is being written. - pdf: Pdf, -} - -/// The initial state: we are exploring the document, collecting all resources -/// that will be necessary later. The content of the pages is also built during -/// this phase. -struct WithDocument<'a> { - /// The Typst document that is exported. - document: &'a PagedDocument, - /// Settings for PDF export. - options: &'a PdfOptions<'a>, -} - -/// At this point, resources were listed, but they don't have any reference -/// associated with them. -/// -/// This phase allocates some global references. -struct WithResources<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - /// The content of the pages encoded as PDF content streams. - /// - /// The pages are at the index corresponding to their page number, but they - /// may be `None` if they are not in the range specified by - /// `exported_pages`. - pages: Vec>, - /// The PDF resources that are used in the content of the pages. - resources: Resources<()>, -} - -/// Global references. -struct GlobalRefs { - /// References for color conversion functions. - color_functions: ColorFunctionRefs, - /// Reference for pages. - /// - /// Items of this vector are `None` if the corresponding page is not - /// exported. - pages: Vec>, - /// References for the resource dictionaries. - resources: ResourcesRefs, -} - -impl<'a> From<(WithDocument<'a>, (Vec>, Resources<()>))> - for WithResources<'a> -{ - fn from( - (previous, (pages, resources)): ( - WithDocument<'a>, - (Vec>, Resources<()>), - ), - ) -> Self { - Self { - document: previous.document, - options: previous.options, - pages, - resources, - } - } -} - -/// At this point, the resources have been collected, and global references have -/// been allocated. -/// -/// We are now writing objects corresponding to resources, and giving them references, -/// that will be collected in [`References`]. -struct WithGlobalRefs<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - pages: Vec>, - /// Resources are the same as in previous phases, but each dictionary now has a reference. - resources: Resources, - /// Global references that were just allocated. - globals: GlobalRefs, -} - -impl<'a> From<(WithResources<'a>, GlobalRefs)> for WithGlobalRefs<'a> { - fn from((previous, globals): (WithResources<'a>, GlobalRefs)) -> Self { - Self { - document: previous.document, - options: previous.options, - pages: previous.pages, - resources: previous.resources.with_refs(&globals.resources), - globals, - } - } -} - -/// The references that have been assigned to each object. -struct References { - /// List of named destinations, each with an ID. - named_destinations: NamedDestinations, - /// The IDs of written fonts. - fonts: HashMap, - /// The IDs of written color fonts. - color_fonts: HashMap, - /// The IDs of written images. - images: HashMap, - /// The IDs of written gradients. - gradients: HashMap, - /// The IDs of written tilings. - tilings: HashMap, - /// The IDs of written external graphics states. - ext_gs: HashMap, - /// The names and references for embedded files. - embedded_files: BTreeMap, -} - -/// At this point, the references have been assigned to all resources. The page -/// tree is going to be written, and given a reference. It is also at this point that -/// the page contents is actually written. -struct WithRefs<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - globals: GlobalRefs, - pages: Vec>, - resources: Resources, - /// References that were allocated for resources. - references: References, -} - -impl<'a> From<(WithGlobalRefs<'a>, References)> for WithRefs<'a> { - fn from((previous, references): (WithGlobalRefs<'a>, References)) -> Self { - Self { - document: previous.document, - options: previous.options, - globals: previous.globals, - pages: previous.pages, - resources: previous.resources, - references, - } - } -} - -/// In this phase, we write resource dictionaries. -/// -/// Each sub-resource gets its own isolated resource dictionary. -struct WithEverything<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - globals: GlobalRefs, - pages: Vec>, - resources: Resources, - references: References, - /// Reference that was allocated for the page tree. - page_tree_ref: Ref, -} - -impl<'a> From<(WithEverything<'a>, ())> for WithEverything<'a> { - fn from((this, _): (WithEverything<'a>, ())) -> Self { - this - } -} - -impl<'a> From<(WithRefs<'a>, Ref)> for WithEverything<'a> { - fn from((previous, page_tree_ref): (WithRefs<'a>, Ref)) -> Self { - Self { - document: previous.document, - options: previous.options, - globals: previous.globals, - resources: previous.resources, - references: previous.references, - pages: previous.pages, - page_tree_ref, - } - } -} - -impl<'a> PdfBuilder> { - /// Start building a PDF for a Typst document. - fn new(document: &'a PagedDocument, options: &'a PdfOptions<'a>) -> Self { - Self { - alloc: Ref::new(1), - pdf: Pdf::new(), - state: WithDocument { document, options }, - } - } -} - -impl PdfBuilder { - /// Start a new phase, and save its output in the global state. - fn phase(mut self, builder: B) -> SourceResult> - where - // New state - NS: From<(S, O)>, - // Builder - B: Fn(&mut Self) -> SourceResult, - { - let output = builder(&mut self)?; - Ok(PdfBuilder { - state: NS::from((self.state, output)), - alloc: self.alloc, - pdf: self.pdf, - }) - } - - /// Run a step with the current state, merges its output into the PDF file, - /// and renumbers any references it returned. - fn run(&mut self, process: P) -> SourceResult - where - // Process - P: Fn(&S) -> SourceResult<(PdfChunk, O)>, - // Output - O: Renumber, - { - let (chunk, mut output) = process(&self.state)?; - // Allocate a final reference for each temporary one - let allocated = chunk.alloc.get() - TEMPORARY_REFS_START; - let offset = TEMPORARY_REFS_START - self.alloc.get(); - - // Merge the chunk into the PDF, using the new references - chunk.renumber_into(&mut self.pdf, |mut r| { - r.renumber(offset); - - r - }); - - // Also update the references in the output - output.renumber(offset); - - self.alloc = Ref::new(self.alloc.get() + allocated); - - Ok(output) - } - - /// Finalize the PDF export and returns the buffer representing the - /// document. - fn export_with

(mut self, process: P) -> SourceResult> - where - P: Fn(S, &mut Pdf, &mut Ref) -> SourceResult<()>, - { - process(self.state, &mut self.pdf, &mut self.alloc)?; - Ok(self.pdf.finish()) - } -} - -/// A reference or collection of references that can be re-numbered, -/// to become valid in a global scope. -trait Renumber { - /// Renumber this value by shifting any references it contains by `offset`. - fn renumber(&mut self, offset: i32); -} - -impl Renumber for () { - fn renumber(&mut self, _offset: i32) {} -} - -impl Renumber for Ref { - fn renumber(&mut self, offset: i32) { - if self.get() >= TEMPORARY_REFS_START { - *self = Ref::new(self.get() - offset); - } - } -} - -impl Renumber for Vec { - fn renumber(&mut self, offset: i32) { - for item in self { - item.renumber(offset); - } - } -} - -impl Renumber for HashMap { - fn renumber(&mut self, offset: i32) { - for v in self.values_mut() { - v.renumber(offset); - } - } -} - -impl Renumber for BTreeMap { - fn renumber(&mut self, offset: i32) { - for v in self.values_mut() { - v.renumber(offset); - } - } -} - -impl Renumber for Option { - fn renumber(&mut self, offset: i32) { - if let Some(r) = self { - r.renumber(offset) - } - } -} - -impl Renumber for (T, R) { - fn renumber(&mut self, offset: i32) { - self.1.renumber(offset) - } -} - -/// A portion of a PDF file. -struct PdfChunk { - /// The actual chunk. - chunk: Chunk, - /// A local allocator. - alloc: Ref, -} - -/// Any reference below that value was already allocated before and -/// should not be rewritten. Anything above was allocated in the current -/// chunk, and should be remapped. -/// -/// This is a constant (large enough to avoid collisions) and not -/// dependent on self.alloc to allow for better memoization of steps, if -/// needed in the future. -const TEMPORARY_REFS_START: i32 = 1_000_000_000; - -/// A part of a PDF document. -impl PdfChunk { - /// Start writing a new part of the document. - fn new() -> Self { - PdfChunk { - chunk: Chunk::new(), - alloc: Ref::new(TEMPORARY_REFS_START), - } - } - - /// Allocate a reference that is valid in the context of this chunk. - /// - /// References allocated with this function should be [renumbered](`Renumber::renumber`) - /// before being used in other chunks. This is done automatically if these - /// references are stored in the global `PdfBuilder` state. - fn alloc(&mut self) -> Ref { - self.alloc.bump() - } -} - -impl Deref for PdfChunk { - type Target = Chunk; - - fn deref(&self) -> &Self::Target { - &self.chunk - } -} - -impl DerefMut for PdfChunk { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.chunk - } -} - -/// Compress data with the DEFLATE algorithm. -fn deflate(data: &[u8]) -> Vec { - const COMPRESSION_LEVEL: u8 = 6; - miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL) -} - -/// Memoized and deferred version of [`deflate`] specialized for a page's content -/// stream. -#[comemo::memoize] -fn deflate_deferred(content: Vec) -> Deferred> { - Deferred::new(move || deflate(&content)) -} - -/// Create a base64-encoded hash of the value. -fn hash_base64(value: &T) -> String { - base64::engine::general_purpose::STANDARD - .encode(typst_utils::hash128(value).to_be_bytes()) -} - -/// Additional methods for [`Abs`]. -trait AbsExt { - /// Convert an to a number of points. - fn to_f32(self) -> f32; -} - -impl AbsExt for Abs { - fn to_f32(self) -> f32 { - self.to_pt() as f32 - } -} - -/// Additional methods for [`Em`]. -trait EmExt { - /// Convert an em length to a number of PDF font units. - fn to_font_units(self) -> f32; -} - -impl EmExt for Em { - fn to_font_units(self) -> f32 { - 1000.0 * self.get() as f32 - } -} - -trait NameExt<'a> { - /// The maximum length of a name in PDF/A. - const PDFA_LIMIT: usize = 127; -} - -impl<'a> NameExt<'a> for Name<'a> {} - -/// Additional methods for [`Str`]. -trait StrExt<'a>: Sized { - /// The maximum length of a string in PDF/A. - const PDFA_LIMIT: usize = 32767; - - /// Create a string that satisfies the constraints of PDF/A. - #[allow(unused)] - fn trimmed(string: &'a [u8]) -> Self; -} - -impl<'a> StrExt<'a> for Str<'a> { - fn trimmed(string: &'a [u8]) -> Self { - Self(&string[..string.len().min(Self::PDFA_LIMIT)]) - } -} - -/// Additional methods for [`TextStr`]. -trait TextStrExt<'a>: Sized { - /// The maximum length of a string in PDF/A. - const PDFA_LIMIT: usize = Str::PDFA_LIMIT; - - /// Create a text string that satisfies the constraints of PDF/A. - fn trimmed(string: &'a str) -> Self; -} - -impl<'a> TextStrExt<'a> for TextStr<'a> { - fn trimmed(string: &'a str) -> Self { - Self(&string[..string.len().min(Self::PDFA_LIMIT)]) - } -} - -/// Extension trait for [`Content`](pdf_writer::Content). -trait ContentExt { - fn save_state_checked(&mut self) -> SourceResult<()>; -} - -impl ContentExt for pdf_writer::Content { - fn save_state_checked(&mut self) -> SourceResult<()> { - self.save_state(); - if self.state_nesting_depth() > 28 { - bail!( - Span::detached(), - "maximum PDF grouping depth exceeding"; - hint: "try to avoid excessive nesting of layout containers", - ); - } - Ok(()) - } -} - -/// Convert to an array of floats. -fn transform_to_array(ts: Transform) -> [f32; 6] { - [ - ts.sx.get() as f32, - ts.ky.get() as f32, - ts.kx.get() as f32, - ts.sy.get() as f32, - ts.tx.to_f32(), - ts.ty.to_f32(), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_timestamp_new_local() { - let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap(); - let test = |whole_minute_offset, expect_timezone| { - assert_eq!( - Timestamp::new_local(dummy_datetime, whole_minute_offset) - .unwrap() - .timezone, - expect_timezone - ); - }; - - // Valid timezone offsets - test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 }); - test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 }); - test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 }); - test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 }); - test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 }); - test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE - - // Corner cases - test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 }); - test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 }); - test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 }); - test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 }); - - // Invalid timezone offsets - assert!(Timestamp::new_local(dummy_datetime, 1440).is_none()); - assert!(Timestamp::new_local(dummy_datetime, -1440).is_none()); - assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none()); - assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none()); - } + /// PDF/A-3u. + #[serde(rename = "a-3u")] + A_3u, + /// PDF/A-4. + #[serde(rename = "a-4")] + A_4, + /// PDF/A-4f. + #[serde(rename = "a-4f")] + A_4f, + /// PDF/A-4e. + #[serde(rename = "a-4e")] + A_4e, } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs new file mode 100644 index 000000000..64cb8f0a2 --- /dev/null +++ b/crates/typst-pdf/src/link.rs @@ -0,0 +1,94 @@ +use krilla::action::{Action, LinkAction}; +use krilla::annotation::{LinkAnnotation, Target}; +use krilla::destination::XyzDestination; +use krilla::geom::Rect; +use typst_library::layout::{Abs, Point, Size}; +use typst_library::model::Destination; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::util::{AbsExt, PointExt}; + +pub(crate) fn handle_link( + fc: &mut FrameContext, + gc: &mut GlobalContext, + dest: &Destination, + size: Size, +) { + let mut min_x = Abs::inf(); + let mut min_y = Abs::inf(); + let mut max_x = -Abs::inf(); + let mut max_y = -Abs::inf(); + + let pos = Point::zero(); + + // Compute the bounding box of the transformed link. + for point in [ + pos, + pos + Point::with_x(size.x), + pos + Point::with_y(size.y), + pos + size.to_point(), + ] { + let t = point.transform(fc.state().transform()); + min_x.set_min(t.x); + min_y.set_min(t.y); + max_x.set_max(t.x); + max_y.set_max(t.y); + } + + let x1 = min_x.to_f32(); + let x2 = max_x.to_f32(); + let y1 = min_y.to_f32(); + let y2 = max_y.to_f32(); + + let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap(); + + // TODO: Support quad points. + + let pos = match dest { + Destination::Url(u) => { + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Action(Action::Link(LinkAction::new(u.to_string()))), + ) + .into(), + ); + return; + } + Destination::Position(p) => *p, + Destination::Location(loc) => { + if let Some(nd) = gc.loc_to_names.get(loc) { + // If a named destination has been registered, it's already guaranteed to + // not point to an excluded page. + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Destination(krilla::destination::Destination::Named( + nd.clone(), + )), + ) + .into(), + ); + return; + } else { + gc.document.introspector.position(*loc) + } + } + }; + + let page_index = pos.page.get() - 1; + if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Destination(krilla::destination::Destination::Xyz( + XyzDestination::new(index, pos.point.to_krilla()), + )), + ) + .into(), + ); + } +} diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs new file mode 100644 index 000000000..589c5e2fb --- /dev/null +++ b/crates/typst-pdf/src/metadata.rs @@ -0,0 +1,184 @@ +use ecow::EcoString; +use krilla::metadata::{Metadata, TextDirection}; +use typst_library::foundations::{Datetime, Smart}; +use typst_library::layout::Dir; +use typst_library::text::Lang; + +use crate::convert::GlobalContext; + +pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { + let creator = format!("Typst {}", env!("CARGO_PKG_VERSION")); + + let lang = gc.languages.iter().max_by_key(|(_, &count)| count).map(|(&l, _)| l); + + let dir = if lang.map(Lang::dir) == Some(Dir::RTL) { + TextDirection::RightToLeft + } else { + TextDirection::LeftToRight + }; + + let mut metadata = Metadata::new() + .creator(creator) + .keywords(gc.document.info.keywords.iter().map(EcoString::to_string).collect()) + .authors(gc.document.info.author.iter().map(EcoString::to_string).collect()); + + let lang = gc.languages.iter().max_by_key(|(_, &count)| count).map(|(&l, _)| l); + + if let Some(lang) = lang { + metadata = metadata.language(lang.as_str().to_string()); + } + + if let Some(title) = &gc.document.info.title { + metadata = metadata.title(title.to_string()); + } + + if let Some(subject) = &gc.document.info.description { + metadata = metadata.subject(subject.to_string()); + } + + if let Some(ident) = gc.options.ident.custom() { + metadata = metadata.document_id(ident.to_string()); + } + + // (1) If the `document.date` is set to specific `datetime` or `none`, use it. + // (2) If the `document.date` is set to `auto` or not set, try to use the + // date from the options. + // (3) Otherwise, we don't write date metadata. + let (date, tz) = match (gc.document.info.date, gc.options.timestamp) { + (Smart::Custom(date), _) => (date, None), + (Smart::Auto, Some(timestamp)) => { + (Some(timestamp.datetime), Some(timestamp.timezone)) + } + _ => (None, None), + }; + + if let Some(date) = date.and_then(|d| convert_date(d, tz)) { + metadata = metadata.creation_date(date); + } + + metadata = metadata.text_direction(dir); + + metadata +} + +fn convert_date( + datetime: Datetime, + tz: Option, +) -> Option { + let year = datetime.year().filter(|&y| y >= 0)? as u16; + + let mut kd = krilla::metadata::DateTime::new(year); + + if let Some(month) = datetime.month() { + kd = kd.month(month); + } + + if let Some(day) = datetime.day() { + kd = kd.day(day); + } + + if let Some(h) = datetime.hour() { + kd = kd.hour(h); + } + + if let Some(m) = datetime.minute() { + kd = kd.minute(m); + } + + if let Some(s) = datetime.second() { + kd = kd.second(s); + } + + match tz { + Some(Timezone::UTC) => kd = kd.utc_offset_hour(0).utc_offset_minute(0), + Some(Timezone::Local { hour_offset, minute_offset }) => { + kd = kd.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset) + } + None => {} + } + + Some(kd) +} + +/// A timestamp with timezone information. +#[derive(Debug, Clone, Copy)] +pub struct Timestamp { + /// The datetime of the timestamp. + pub(crate) datetime: Datetime, + /// The timezone of the timestamp. + pub(crate) timezone: Timezone, +} + +impl Timestamp { + /// Create a new timestamp with a given datetime and UTC suffix. + pub fn new_utc(datetime: Datetime) -> Self { + Self { datetime, timezone: Timezone::UTC } + } + + /// Create a new timestamp with a given datetime, and a local timezone offset. + pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option { + let hour_offset = (whole_minute_offset / 60).try_into().ok()?; + // Note: the `%` operator in Rust is the remainder operator, not the + // modulo operator. The remainder operator can return negative results. + // We can simply apply `abs` here because we assume the `minute_offset` + // will have the same sign as `hour_offset`. + let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?; + match (hour_offset, minute_offset) { + // Only accept valid timezone offsets with `-23 <= hours <= 23`, + // and `0 <= minutes <= 59`. + (-23..=23, 0..=59) => Some(Self { + datetime, + timezone: Timezone::Local { hour_offset, minute_offset }, + }), + _ => None, + } + } +} + +/// A timezone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Timezone { + /// The UTC timezone. + UTC, + /// The local timezone offset from UTC. And the `minute_offset` will have + /// same sign as `hour_offset`. + Local { hour_offset: i8, minute_offset: u8 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timestamp_new_local() { + let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap(); + let test = |whole_minute_offset, expect_timezone| { + assert_eq!( + Timestamp::new_local(dummy_datetime, whole_minute_offset) + .unwrap() + .timezone, + expect_timezone + ); + }; + + // Valid timezone offsets + test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 }); + test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 }); + test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 }); + test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 }); + test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 }); + test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE + + // Corner cases + test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 }); + test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 }); + test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 }); + test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 }); + + // Invalid timezone offsets + assert!(Timestamp::new_local(dummy_datetime, 1440).is_none()); + assert!(Timestamp::new_local(dummy_datetime, -1440).is_none()); + assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none()); + assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none()); + } +} diff --git a/crates/typst-pdf/src/named_destination.rs b/crates/typst-pdf/src/named_destination.rs deleted file mode 100644 index 7ae2c5e6f..000000000 --- a/crates/typst-pdf/src/named_destination.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use pdf_writer::writers::Destination; -use pdf_writer::{Ref, Str}; -use typst_library::diag::SourceResult; -use typst_library::foundations::{Label, NativeElement}; -use typst_library::introspection::Location; -use typst_library::layout::Abs; -use typst_library::model::HeadingElem; - -use crate::{AbsExt, PdfChunk, Renumber, StrExt, WithGlobalRefs}; - -/// A list of destinations in the PDF document (a specific point on a specific -/// page), that have a name associated with them. -/// -/// Typst creates a named destination for each heading in the document, that -/// will then be written in the document catalog. PDF readers can then display -/// them to show a clickable outline of the document. -#[derive(Default)] -pub struct NamedDestinations { - /// A map between elements and their associated labels - pub loc_to_dest: HashMap, - /// A sorted list of all named destinations. - pub dests: Vec<(Label, Ref)>, -} - -impl Renumber for NamedDestinations { - fn renumber(&mut self, offset: i32) { - for (_, reference) in &mut self.dests { - reference.renumber(offset); - } - } -} - -/// Fills in the map and vector for named destinations and writes the indirect -/// destination objects. -pub fn write_named_destinations( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, NamedDestinations)> { - let mut chunk = PdfChunk::new(); - let mut out = NamedDestinations::default(); - let mut seen = HashSet::new(); - - // Find all headings that have a label and are the first among other - // headings with the same label. - let mut matches: Vec<_> = context - .document - .introspector - .query(&HeadingElem::elem().select()) - .iter() - .filter_map(|elem| elem.location().zip(elem.label())) - .filter(|&(_, label)| seen.insert(label)) - .collect(); - - // Named destinations must be sorted by key. - matches.sort_by_key(|&(_, label)| label.resolve()); - - for (loc, label) in matches { - // Don't encode named destinations that would exceed the limit. Those - // will instead be encoded as normal links. - if label.resolve().len() > Str::PDFA_LIMIT { - continue; - } - - let pos = context.document.introspector.position(loc); - let index = pos.page.get() - 1; - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - - if let Some((Some(page), Some(page_ref))) = - context.pages.get(index).zip(context.globals.pages.get(index)) - { - let dest_ref = chunk.alloc(); - let x = pos.point.x.to_f32(); - let y = (page.content.size.y - y).to_f32(); - out.dests.push((label, dest_ref)); - out.loc_to_dest.insert(loc, label); - chunk - .indirect(dest_ref) - .start::() - .page(*page_ref) - .xyz(x, y, None); - } - } - - Ok((chunk, out)) -} diff --git a/crates/typst-pdf/src/outline.rs b/crates/typst-pdf/src/outline.rs index eff1182c1..e6324309f 100644 --- a/crates/typst-pdf/src/outline.rs +++ b/crates/typst-pdf/src/outline.rs @@ -1,18 +1,15 @@ use std::num::NonZeroUsize; -use pdf_writer::{Finish, Pdf, Ref, TextStr}; +use krilla::destination::XyzDestination; +use krilla::outline::{Outline, OutlineNode}; use typst_library::foundations::{NativeElement, Packed, StyleChain}; use typst_library::layout::Abs; use typst_library::model::HeadingElem; -use crate::{AbsExt, TextStrExt, WithEverything}; +use crate::convert::GlobalContext; +use crate::util::AbsExt; -/// Construct the outline for the document. -pub(crate) fn write_outline( - chunk: &mut Pdf, - alloc: &mut Ref, - ctx: &WithEverything, -) -> Option { +pub(crate) fn build_outline(gc: &GlobalContext) -> Outline { let mut tree: Vec = vec![]; // Stores the level of the topmost skipped ancestor of the next bookmarked @@ -21,14 +18,14 @@ pub(crate) fn write_outline( // Therefore, its next descendant must be added at its level, which is // enforced in the manner shown below. let mut last_skipped_level = None; - let elements = ctx.document.introspector.query(&HeadingElem::elem().select()); + let elements = &gc.document.introspector.query(&HeadingElem::elem().select()); for elem in elements.iter() { - if let Some(page_ranges) = &ctx.options.page_ranges { + if let Some(page_ranges) = &gc.options.page_ranges { if !page_ranges - .includes_page(ctx.document.introspector.page(elem.location().unwrap())) + .includes_page(gc.document.introspector.page(elem.location().unwrap())) { - // Don't bookmark headings in non-exported pages + // Don't bookmark headings in non-exported pages. continue; } } @@ -95,39 +92,15 @@ pub(crate) fn write_outline( } } - if tree.is_empty() { - return None; + let mut outline = Outline::new(); + + for child in convert_nodes(&tree, gc) { + outline.push_child(child); } - let root_id = alloc.bump(); - let start_ref = *alloc; - let len = tree.len(); - - let mut prev_ref = None; - for (i, node) in tree.iter().enumerate() { - prev_ref = Some(write_outline_item( - ctx, - chunk, - alloc, - node, - root_id, - prev_ref, - i + 1 == len, - )); - } - - chunk - .outline(root_id) - .first(start_ref) - .last(Ref::new( - alloc.get() - tree.last().map(|child| child.len() as i32).unwrap_or(1), - )) - .count(tree.len() as i32); - - Some(root_id) + outline } -/// A heading in the outline panel. #[derive(Debug)] struct HeadingNode<'a> { element: &'a Packed, @@ -149,73 +122,31 @@ impl<'a> HeadingNode<'a> { } } - fn len(&self) -> usize { - 1 + self.children.iter().map(Self::len).sum::() + fn to_krilla(&self, gc: &GlobalContext) -> Option { + let loc = self.element.location().unwrap(); + let title = self.element.body.plain_text().to_string(); + let pos = gc.document.introspector.position(loc); + let page_index = pos.page.get() - 1; + + if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { + let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); + let dest = XyzDestination::new( + index, + krilla::geom::Point::from_xy(pos.point.x.to_f32(), y.to_f32()), + ); + + let mut outline_node = OutlineNode::new(title, dest); + for child in convert_nodes(&self.children, gc) { + outline_node.push_child(child); + } + + return Some(outline_node); + } + + None } } -/// Write an outline item and all its children. -fn write_outline_item( - ctx: &WithEverything, - chunk: &mut Pdf, - alloc: &mut Ref, - node: &HeadingNode, - parent_ref: Ref, - prev_ref: Option, - is_last: bool, -) -> Ref { - let id = alloc.bump(); - let next_ref = Ref::new(id.get() + node.len() as i32); - - let mut outline = chunk.outline_item(id); - outline.parent(parent_ref); - - if !is_last { - outline.next(next_ref); - } - - if let Some(prev_rev) = prev_ref { - outline.prev(prev_rev); - } - - if let Some(last_immediate_child) = node.children.last() { - outline.first(Ref::new(id.get() + 1)); - outline.last(Ref::new(next_ref.get() - last_immediate_child.len() as i32)); - outline.count(-(node.children.len() as i32)); - } - - outline.title(TextStr::trimmed(node.element.body.plain_text().trim())); - - let loc = node.element.location().unwrap(); - let pos = ctx.document.introspector.position(loc); - let index = pos.page.get() - 1; - - // Don't link to non-exported pages. - if let Some((Some(page), Some(page_ref))) = - ctx.pages.get(index).zip(ctx.globals.pages.get(index)) - { - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - outline.dest().page(*page_ref).xyz( - pos.point.x.to_f32(), - (page.content.size.y - y).to_f32(), - None, - ); - } - - outline.finish(); - - let mut prev_ref = None; - for (i, child) in node.children.iter().enumerate() { - prev_ref = Some(write_outline_item( - ctx, - chunk, - alloc, - child, - id, - prev_ref, - i + 1 == node.children.len(), - )); - } - - id +fn convert_nodes(nodes: &[HeadingNode], gc: &GlobalContext) -> Vec { + nodes.iter().flat_map(|node| node.to_krilla(gc)).collect() } diff --git a/crates/typst-pdf/src/page.rs b/crates/typst-pdf/src/page.rs index 68125d29a..aa34400ef 100644 --- a/crates/typst-pdf/src/page.rs +++ b/crates/typst-pdf/src/page.rs @@ -1,310 +1,64 @@ -use std::collections::HashMap; -use std::num::NonZeroU64; +use std::num::NonZeroUsize; -use ecow::EcoString; -use pdf_writer::types::{ActionType, AnnotationFlags, AnnotationType, NumberingStyle}; -use pdf_writer::{Filter, Finish, Name, Rect, Ref, Str}; -use typst_library::diag::SourceResult; -use typst_library::foundations::Label; -use typst_library::introspection::Location; -use typst_library::layout::{Abs, Page}; -use typst_library::model::{Destination, Numbering}; +use krilla::page::{NumberingStyle, PageLabel}; +use typst_library::model::Numbering; -use crate::{ - content, AbsExt, PdfChunk, PdfOptions, Resources, WithDocument, WithRefs, - WithResources, -}; - -/// Construct page objects. -#[typst_macros::time(name = "construct pages")] -#[allow(clippy::type_complexity)] -pub fn traverse_pages( - state: &WithDocument, -) -> SourceResult<(PdfChunk, (Vec>, Resources<()>))> { - let mut resources = Resources::default(); - let mut pages = Vec::with_capacity(state.document.pages.len()); - let mut skipped_pages = 0; - for (i, page) in state.document.pages.iter().enumerate() { - if state - .options - .page_ranges - .as_ref() - .is_some_and(|ranges| !ranges.includes_page_index(i)) - { - // Don't export this page. - pages.push(None); - skipped_pages += 1; - } else { - let mut encoded = construct_page(state.options, &mut resources, page)?; - encoded.label = page - .numbering - .as_ref() - .and_then(|num| PdfPageLabel::generate(num, page.number)) - .or_else(|| { - // When some pages were ignored from export, we show a page label with - // the correct real (not logical) page number. - // This is for consistency with normal output when pages have no numbering - // and all are exported: the final PDF page numbers always correspond to - // the real (not logical) page numbers. Here, the final PDF page number - // will differ, but we can at least use labels to indicate what was - // the corresponding real page number in the Typst document. - (skipped_pages > 0).then(|| PdfPageLabel::arabic((i + 1) as u64)) - }); - pages.push(Some(encoded)); - } - } - - Ok((PdfChunk::new(), (pages, resources))) -} - -/// Construct a page object. -#[typst_macros::time(name = "construct page")] -fn construct_page( - options: &PdfOptions, - out: &mut Resources<()>, - page: &Page, -) -> SourceResult { - Ok(EncodedPage { - content: content::build( - options, - out, - &page.frame, - page.fill_or_transparent(), - None, - )?, - label: None, - }) -} - -/// Allocate a reference for each exported page. -pub fn alloc_page_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, Vec>)> { - let mut chunk = PdfChunk::new(); - let page_refs = context - .pages - .iter() - .map(|p| p.as_ref().map(|_| chunk.alloc())) - .collect(); - Ok((chunk, page_refs)) -} - -/// Write the page tree. -pub fn write_page_tree(ctx: &WithRefs) -> SourceResult<(PdfChunk, Ref)> { - let mut chunk = PdfChunk::new(); - let page_tree_ref = chunk.alloc.bump(); - - for i in 0..ctx.pages.len() { - let content_id = chunk.alloc.bump(); - write_page( - &mut chunk, - ctx, - content_id, - page_tree_ref, - &ctx.references.named_destinations.loc_to_dest, - i, - ); - } - - let page_kids = ctx.globals.pages.iter().filter_map(Option::as_ref).copied(); - - chunk - .pages(page_tree_ref) - .count(page_kids.clone().count() as i32) - .kids(page_kids); - - Ok((chunk, page_tree_ref)) -} - -/// Write a page tree node. -fn write_page( - chunk: &mut PdfChunk, - ctx: &WithRefs, - content_id: Ref, - page_tree_ref: Ref, - loc_to_dest: &HashMap, - i: usize, -) { - let Some((page, page_ref)) = ctx.pages[i].as_ref().zip(ctx.globals.pages[i]) else { - // Page excluded from export. - return; - }; - - let mut annotations = Vec::with_capacity(page.content.links.len()); - for (dest, rect) in &page.content.links { - let id = chunk.alloc(); - annotations.push(id); - - let mut annotation = chunk.annotation(id); - annotation.subtype(AnnotationType::Link).rect(*rect); - annotation.border(0.0, 0.0, 0.0, None).flags(AnnotationFlags::PRINT); - - let pos = match dest { - Destination::Url(uri) => { - annotation - .action() - .action_type(ActionType::Uri) - .uri(Str(uri.as_bytes())); - continue; - } - Destination::Position(pos) => *pos, - Destination::Location(loc) => { - if let Some(key) = loc_to_dest.get(loc) { - annotation - .action() - .action_type(ActionType::GoTo) - // `key` must be a `Str`, not a `Name`. - .pair(Name(b"D"), Str(key.resolve().as_bytes())); - continue; - } else { - ctx.document.introspector.position(*loc) - } - } - }; - - let index = pos.page.get() - 1; - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - - // Don't add links to non-exported pages. - if let Some((Some(page), Some(page_ref))) = - ctx.pages.get(index).zip(ctx.globals.pages.get(index)) - { - annotation - .action() - .action_type(ActionType::GoTo) - .destination() - .page(*page_ref) - .xyz(pos.point.x.to_f32(), (page.content.size.y - y).to_f32(), None); - } - } - - let mut page_writer = chunk.page(page_ref); - page_writer.parent(page_tree_ref); - - let w = page.content.size.x.to_f32(); - let h = page.content.size.y.to_f32(); - page_writer.media_box(Rect::new(0.0, 0.0, w, h)); - page_writer.contents(content_id); - page_writer.pair(Name(b"Resources"), ctx.resources.reference); - - if page.content.uses_opacities { - page_writer - .group() - .transparency() - .isolated(false) - .knockout(false) - .color_space() - .srgb(); - } - - page_writer.annotations(annotations); - - page_writer.finish(); - - chunk - .stream(content_id, page.content.content.wait()) - .filter(Filter::FlateDecode); -} - -/// Specification for a PDF page label. -#[derive(Debug, Clone, PartialEq, Hash, Default)] -pub(crate) struct PdfPageLabel { - /// Can be any string or none. Will always be prepended to the numbering style. - pub prefix: Option, - /// Based on the numbering pattern. - /// - /// If `None` or numbering is a function, the field will be empty. - pub style: Option, - /// Offset for the page label start. - /// - /// Describes where to start counting from when setting a style. - /// (Has to be greater or equal than 1) - pub offset: Option, -} - -/// A PDF page label number style. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum PdfPageLabelStyle { - /// Decimal arabic numerals (1, 2, 3). - Arabic, - /// Lowercase roman numerals (i, ii, iii). - LowerRoman, - /// Uppercase roman numerals (I, II, III). - UpperRoman, - /// Lowercase letters (`a` to `z` for the first 26 pages, - /// `aa` to `zz` and so on for the next). - LowerAlpha, - /// Uppercase letters (`A` to `Z` for the first 26 pages, - /// `AA` to `ZZ` and so on for the next). - UpperAlpha, -} - -impl PdfPageLabel { - /// Create a new `PdfNumbering` from a `Numbering` applied to a page +pub(crate) trait PageLabelExt { + /// Create a new `PageLabel` from a `Numbering` applied to a page /// number. - fn generate(numbering: &Numbering, number: u64) -> Option { - let Numbering::Pattern(pat) = numbering else { - return None; - }; - - let (prefix, kind) = pat.pieces.first()?; - - // If there is a suffix, we cannot use the common style optimisation, - // since PDF does not provide a suffix field. - let style = if pat.suffix.is_empty() { - use typst_library::model::NumberingKind as Kind; - use PdfPageLabelStyle as Style; - match kind { - Kind::Arabic => Some(Style::Arabic), - Kind::LowerRoman => Some(Style::LowerRoman), - Kind::UpperRoman => Some(Style::UpperRoman), - Kind::LowerLatin if number <= 26 => Some(Style::LowerAlpha), - Kind::LowerLatin if number <= 26 => Some(Style::UpperAlpha), - _ => None, - } - } else { - None - }; - - // Prefix and offset depend on the style: If it is supported by the PDF - // spec, we use the given prefix and an offset. Otherwise, everything - // goes into prefix. - let prefix = if style.is_none() { - Some(pat.apply(&[number])) - } else { - (!prefix.is_empty()).then(|| prefix.clone()) - }; - - let offset = style.and(NonZeroU64::new(number)); - Some(PdfPageLabel { prefix, style, offset }) - } + fn generate(numbering: &Numbering, number: u64) -> Option; /// Creates an arabic page label with the specified page number. /// For example, this will display page label `11` when given the page /// number 11. - fn arabic(number: u64) -> PdfPageLabel { - PdfPageLabel { - prefix: None, - style: Some(PdfPageLabelStyle::Arabic), - offset: NonZeroU64::new(number), - } - } + fn arabic(number: u64) -> PageLabel; } -impl PdfPageLabelStyle { - pub fn to_pdf_numbering_style(self) -> NumberingStyle { - match self { - PdfPageLabelStyle::Arabic => NumberingStyle::Arabic, - PdfPageLabelStyle::LowerRoman => NumberingStyle::LowerRoman, - PdfPageLabelStyle::UpperRoman => NumberingStyle::UpperRoman, - PdfPageLabelStyle::LowerAlpha => NumberingStyle::LowerAlpha, - PdfPageLabelStyle::UpperAlpha => NumberingStyle::UpperAlpha, +impl PageLabelExt for PageLabel { + fn generate(numbering: &Numbering, number: u64) -> Option { + { + let Numbering::Pattern(pat) = numbering else { + return None; + }; + + let (prefix, kind) = pat.pieces.first()?; + + // If there is a suffix, we cannot use the common style optimisation, + // since PDF does not provide a suffix field. + let style = if pat.suffix.is_empty() { + use krilla::page::NumberingStyle as Style; + use typst_library::model::NumberingKind as Kind; + match kind { + Kind::Arabic => Some(Style::Arabic), + Kind::LowerRoman => Some(Style::LowerRoman), + Kind::UpperRoman => Some(Style::UpperRoman), + Kind::LowerLatin if number <= 26 => Some(Style::LowerAlpha), + Kind::LowerLatin if number <= 26 => Some(Style::UpperAlpha), + _ => None, + } + } else { + None + }; + + // Prefix and offset depend on the style: If it is supported by the PDF + // spec, we use the given prefix and an offset. Otherwise, everything + // goes into prefix. + let prefix = if style.is_none() { + Some(pat.apply(&[number])) + } else { + (!prefix.is_empty()).then(|| prefix.clone()) + }; + + let offset = style.and(number.try_into().ok().and_then(NonZeroUsize::new)); + Some(PageLabel::new(style, prefix.map(|s| s.to_string()), offset)) } } -} -/// Data for an exported page. -pub struct EncodedPage { - pub content: content::Encoded, - pub label: Option, + fn arabic(number: u64) -> PageLabel { + PageLabel::new( + Some(NumberingStyle::Arabic), + None, + number.try_into().ok().and_then(NonZeroUsize::new), + ) + } } diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs new file mode 100644 index 000000000..5224464ab --- /dev/null +++ b/crates/typst-pdf/src/paint.rs @@ -0,0 +1,379 @@ +//! Convert paint types from typst to krilla. + +use krilla::color::{self, cmyk, luma, rgb}; +use krilla::num::NormalizedF32; +use krilla::paint::{ + Fill, LinearGradient, Pattern, RadialGradient, SpreadMethod, Stop, Stroke, + StrokeDash, SweepGradient, +}; +use krilla::surface::Surface; +use typst_library::diag::SourceResult; +use typst_library::layout::{Abs, Angle, Quadrant, Ratio, Size, Transform}; +use typst_library::visualize::{ + Color, ColorSpace, DashPattern, FillRule, FixedStroke, Gradient, Paint, RatioOrAngle, + RelativeTo, Tiling, WeightedColor, +}; +use typst_utils::Numeric; + +use crate::convert::{handle_frame, FrameContext, GlobalContext, State}; +use crate::util::{AbsExt, FillRuleExt, LineCapExt, LineJoinExt, TransformExt}; + +pub(crate) fn convert_fill( + gc: &mut GlobalContext, + paint_: &Paint, + fill_rule_: FillRule, + on_text: bool, + surface: &mut Surface, + state: &State, + size: Size, +) -> SourceResult { + let (paint, opacity) = convert_paint(gc, paint_, on_text, surface, state, size)?; + + Ok(Fill { + paint, + rule: fill_rule_.to_krilla(), + opacity: NormalizedF32::new(opacity as f32 / 255.0).unwrap(), + }) +} + +pub(crate) fn convert_stroke( + fc: &mut GlobalContext, + stroke: &FixedStroke, + on_text: bool, + surface: &mut Surface, + state: &State, + size: Size, +) -> SourceResult { + let (paint, opacity) = + convert_paint(fc, &stroke.paint, on_text, surface, state, size)?; + + Ok(Stroke { + paint, + width: stroke.thickness.to_f32(), + miter_limit: stroke.miter_limit.get() as f32, + line_join: stroke.join.to_krilla(), + line_cap: stroke.cap.to_krilla(), + opacity: NormalizedF32::new(opacity as f32 / 255.0).unwrap(), + dash: stroke.dash.as_ref().map(convert_dash), + }) +} + +fn convert_paint( + gc: &mut GlobalContext, + paint: &Paint, + on_text: bool, + surface: &mut Surface, + state: &State, + mut size: Size, +) -> SourceResult<(krilla::paint::Paint, u8)> { + // Edge cases for strokes. + if size.x.is_zero() { + size.x = Abs::pt(1.0); + } + + if size.y.is_zero() { + size.y = Abs::pt(1.0); + } + + match paint { + Paint::Solid(c) => { + let (c, a) = convert_solid(c); + Ok((c.into(), a)) + } + Paint::Gradient(g) => Ok(convert_gradient(g, on_text, state, size)), + Paint::Tiling(p) => convert_pattern(gc, p, on_text, surface, state), + } +} + +fn convert_solid(color: &Color) -> (color::Color, u8) { + match color.space() { + ColorSpace::D65Gray => { + let (c, a) = convert_luma(color); + (c.into(), a) + } + ColorSpace::Cmyk => (convert_cmyk(color).into(), 255), + // Convert all other colors in different colors spaces into RGB. + _ => { + let (c, a) = convert_rgb(color); + (c.into(), a) + } + } +} + +fn convert_cmyk(color: &Color) -> cmyk::Color { + let components = color.to_space(ColorSpace::Cmyk).to_vec4_u8(); + + cmyk::Color::new(components[0], components[1], components[2], components[3]) +} + +fn convert_rgb(color: &Color) -> (rgb::Color, u8) { + let components = color.to_space(ColorSpace::Srgb).to_vec4_u8(); + (rgb::Color::new(components[0], components[1], components[2]), components[3]) +} + +fn convert_luma(color: &Color) -> (luma::Color, u8) { + let components = color.to_space(ColorSpace::D65Gray).to_vec4_u8(); + (luma::Color::new(components[0]), components[3]) +} + +fn convert_pattern( + gc: &mut GlobalContext, + pattern: &Tiling, + on_text: bool, + surface: &mut Surface, + state: &State, +) -> SourceResult<(krilla::paint::Paint, u8)> { + let transform = correct_transform(state, pattern.unwrap_relative(on_text)); + + let mut stream_builder = surface.stream_builder(); + let mut surface = stream_builder.surface(); + let mut fc = FrameContext::new(pattern.frame().size()); + handle_frame(&mut fc, pattern.frame(), None, &mut surface, gc)?; + surface.finish(); + let stream = stream_builder.finish(); + let pattern = Pattern { + stream, + transform: transform.to_krilla(), + width: (pattern.size().x + pattern.spacing().x).to_pt() as _, + height: (pattern.size().y + pattern.spacing().y).to_pt() as _, + }; + + Ok((pattern.into(), 255)) +} + +fn convert_gradient( + gradient: &Gradient, + on_text: bool, + state: &State, + size: Size, +) -> (krilla::paint::Paint, u8) { + let size = match gradient.unwrap_relative(on_text) { + RelativeTo::Self_ => size, + RelativeTo::Parent => state.container_size(), + }; + + let angle = gradient.angle().unwrap_or_else(Angle::zero); + let base_transform = correct_transform(state, gradient.unwrap_relative(on_text)); + let stops = convert_gradient_stops(gradient); + match &gradient { + Gradient::Linear(_) => { + let (x1, y1, x2, y2) = { + let (mut sin, mut cos) = (angle.sin(), angle.cos()); + + // Scale to edges of unit square. + let factor = cos.abs() + sin.abs(); + sin *= factor; + cos *= factor; + + match angle.quadrant() { + Quadrant::First => (0.0, 0.0, cos as f32, sin as f32), + Quadrant::Second => (1.0, 0.0, cos as f32 + 1.0, sin as f32), + Quadrant::Third => (1.0, 1.0, cos as f32 + 1.0, sin as f32 + 1.0), + Quadrant::Fourth => (0.0, 1.0, cos as f32, sin as f32 + 1.0), + } + }; + + let linear = LinearGradient { + x1, + y1, + x2, + y2, + // x and y coordinates are normalized, so need to scale by the size. + transform: base_transform + .pre_concat(Transform::scale( + Ratio::new(size.x.to_f32() as f64), + Ratio::new(size.y.to_f32() as f64), + )) + .to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (linear.into(), 255) + } + Gradient::Radial(radial) => { + let radial = RadialGradient { + fx: radial.focal_center.x.get() as f32, + fy: radial.focal_center.y.get() as f32, + fr: radial.focal_radius.get() as f32, + cx: radial.center.x.get() as f32, + cy: radial.center.y.get() as f32, + cr: radial.radius.get() as f32, + transform: base_transform + .pre_concat(Transform::scale( + Ratio::new(size.x.to_f32() as f64), + Ratio::new(size.y.to_f32() as f64), + )) + .to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (radial.into(), 255) + } + Gradient::Conic(conic) => { + // Correct the gradient's angle. + let cx = size.x.to_f32() * conic.center.x.get() as f32; + let cy = size.y.to_f32() * conic.center.y.get() as f32; + let actual_transform = base_transform + // Adjust for the angle. + .pre_concat(Transform::rotate_at( + angle, + Abs::pt(cx as f64), + Abs::pt(cy as f64), + )) + // Default start point in krilla and typst are at the opposite side, so we need + // to flip it horizontally. + .pre_concat(Transform::scale_at( + -Ratio::one(), + Ratio::one(), + Abs::pt(cx as f64), + Abs::pt(cy as f64), + )); + + let sweep = SweepGradient { + cx, + cy, + start_angle: 0.0, + end_angle: 360.0, + transform: actual_transform.to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (sweep.into(), 255) + } + } +} + +fn convert_gradient_stops(gradient: &Gradient) -> Vec { + let mut stops = vec![]; + + let use_cmyk = gradient.stops().iter().all(|s| s.color.space() == ColorSpace::Cmyk); + + let mut add_single = |color: &Color, offset: Ratio| { + let (color, opacity) = if use_cmyk { + (convert_cmyk(color).into(), 255) + } else { + let (c, a) = convert_rgb(color); + (c.into(), a) + }; + + let opacity = NormalizedF32::new((opacity as f32) / 255.0).unwrap(); + let offset = NormalizedF32::new(offset.get() as f32).unwrap(); + let stop = Stop { offset, color, opacity }; + stops.push(stop); + }; + + // Convert stops. + match &gradient { + Gradient::Linear(_) | Gradient::Radial(_) => { + if let Some(s) = gradient.stops().first() { + add_single(&s.color, s.offset.unwrap()); + } + + // Create the individual gradient functions for each pair of stops. + for window in gradient.stops().windows(2) { + let (first, second) = (window[0], window[1]); + + // If we have a hue index or are using Oklab, we will create several + // stops in-between to make the gradient smoother without interpolation + // issues with native color spaces. + if gradient.space().hue_index().is_some() { + for i in 0..=32 { + let t = i as f64 / 32.0; + let real_t = Ratio::new( + first.offset.unwrap().get() * (1.0 - t) + + second.offset.unwrap().get() * t, + ); + + let c = gradient.sample(RatioOrAngle::Ratio(real_t)); + add_single(&c, real_t); + } + } + + add_single(&second.color, second.offset.unwrap()); + } + } + Gradient::Conic(conic) => { + if let Some((c, t)) = conic.stops.first() { + add_single(c, *t); + } + + for window in conic.stops.windows(2) { + let ((c0, t0), (c1, t1)) = (window[0], window[1]); + + // Precision: + // - On an even color, insert a stop every 90deg. + // - For a hue-based color space, insert 200 stops minimum. + // - On any other, insert 20 stops minimum. + let max_dt = if c0 == c1 { + 0.25 + } else if conic.space.hue_index().is_some() { + 0.005 + } else { + 0.05 + }; + + let mut t_x = t0.get(); + let dt = (t1.get() - t0.get()).min(max_dt); + + // Special casing for sharp gradients. + if t0 == t1 { + add_single(&c1, t1); + continue; + } + + while t_x < t1.get() { + let t_next = (t_x + dt).min(t1.get()); + + // The current progress in the current window. + let t = |t| (t - t0.get()) / (t1.get() - t0.get()); + + let c_next = Color::mix_iter( + [ + WeightedColor::new(c0, 1.0 - t(t_next)), + WeightedColor::new(c1, t(t_next)), + ], + conic.space, + ) + .unwrap(); + + add_single(&c_next, Ratio::new(t_next)); + t_x = t_next; + } + + add_single(&c1, t1); + } + } + } + + stops +} + +fn convert_dash(dash: &DashPattern) -> StrokeDash { + StrokeDash { + array: dash.array.iter().map(|e| e.to_f32()).collect(), + offset: dash.phase.to_f32(), + } +} + +fn correct_transform(state: &State, relative: RelativeTo) -> Transform { + // In krilla, if we have a shape with a transform and a complex paint, + // then the paint will inherit the transform of the shape. + match relative { + // Because of the above, we don't need to apply an additional transform here. + RelativeTo::Self_ => Transform::identity(), + // Because of the above, we need to first reverse the transform that will be + // applied from the shape, and then re-apply the transform that is used for + // the next parent container. + RelativeTo::Parent => state + .transform() + .invert() + .unwrap() + .pre_concat(state.container_transform()), + } +} diff --git a/crates/typst-pdf/src/resources.rs b/crates/typst-pdf/src/resources.rs deleted file mode 100644 index bdbf2f1e4..000000000 --- a/crates/typst-pdf/src/resources.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! PDF resources. -//! -//! Resources are defined in dictionaries. They map identifiers such as `Im0` to -//! a PDF reference. Each [content stream] is associated with a resource dictionary. -//! The identifiers defined in the resources can then be used in content streams. -//! -//! [content stream]: `crate::content` - -use std::collections::{BTreeMap, HashMap}; -use std::hash::Hash; - -use ecow::{eco_format, EcoString}; -use pdf_writer::{Dict, Finish, Name, Ref}; -use subsetter::GlyphRemapper; -use typst_library::diag::{SourceResult, StrResult}; -use typst_library::text::{Font, Lang}; -use typst_library::visualize::Image; -use typst_syntax::Span; -use typst_utils::Deferred; - -use crate::color::ColorSpaces; -use crate::color_font::ColorFontMap; -use crate::extg::ExtGState; -use crate::gradient::PdfGradient; -use crate::image::EncodedImage; -use crate::tiling::TilingRemapper; -use crate::{PdfChunk, Renumber, WithEverything, WithResources}; - -/// All the resources that have been collected when traversing the document. -/// -/// This does not allocate references to resources, only track what was used -/// and deduplicate what can be deduplicated. -/// -/// You may notice that this structure is a tree: [`TilingRemapper`] and -/// [`ColorFontMap`] (that are present in the fields of [`Resources`]), -/// themselves contain [`Resources`] (that will be called "sub-resources" from -/// now on). Because color glyphs and tilings are defined using content -/// streams, just like pages, they can refer to resources too, which are tracked -/// by the respective sub-resources. -/// -/// Each instance of this structure will become a `/Resources` dictionary in -/// the final PDF. It is not possible to use a single shared dictionary for all -/// pages, tilings and color fonts, because if a resource is listed in its own -/// `/Resources` dictionary, some PDF readers will fail to open the document. -/// -/// Because we need to lazily initialize sub-resources (we don't know how deep -/// the tree will be before reading the document), and that this is done in a -/// context where no PDF reference allocator is available, `Resources` are -/// originally created with the type parameter `R = ()`. The reference for each -/// dictionary will only be allocated in the next phase, once we know the shape -/// of the tree, at which point `R` becomes `Ref`. No other value of `R` should -/// ever exist. -pub struct Resources { - /// The global reference to this resource dictionary, or `()` if it has not - /// been allocated yet. - pub reference: R, - - /// Handles color space writing. - pub colors: ColorSpaces, - - /// Deduplicates fonts used across the document. - pub fonts: Remapper, - /// Deduplicates images used across the document. - pub images: Remapper, - /// Handles to deferred image conversions. - pub deferred_images: HashMap>, Span)>, - /// Deduplicates gradients used across the document. - pub gradients: Remapper, - /// Deduplicates tilings used across the document. - pub tilings: Option>>, - /// Deduplicates external graphics states used across the document. - pub ext_gs: Remapper, - /// Deduplicates color glyphs. - pub color_fonts: Option>>, - - // The fields below do not correspond to actual resources that will be - // written in a dictionary, but are more meta-data about resources that - // can't really live somewhere else. - /// The number of glyphs for all referenced languages in the content stream. - /// We keep track of this to determine the main document language. - /// BTreeMap is used to write sorted list of languages to metadata. - pub languages: BTreeMap, - - /// For each font a mapping from used glyphs to their text representation. - /// This is used for the PDF's /ToUnicode map, and important for copy-paste - /// and searching. - /// - /// Note that the text representation may contain multiple chars in case of - /// ligatures or similar things, and it may have no entry in the font's cmap - /// (or only a private-use codepoint), like the “Th” in Linux Libertine. - /// - /// A glyph may have multiple entries in the font's cmap, and even the same - /// glyph can have a different text representation within one document. - /// But /ToUnicode does not support that, so we just save the first occurrence. - pub glyph_sets: HashMap>, - /// Same as `glyph_sets`, but for color fonts. - pub color_glyph_sets: HashMap>, - /// Stores the glyph remapper for each font for the subsetter. - pub glyph_remappers: HashMap, -} - -impl Renumber for Resources { - fn renumber(&mut self, offset: i32) { - self.reference.renumber(offset); - - if let Some(color_fonts) = &mut self.color_fonts { - color_fonts.resources.renumber(offset); - } - - if let Some(tilings) = &mut self.tilings { - tilings.resources.renumber(offset); - } - } -} - -impl Default for Resources<()> { - fn default() -> Self { - Resources { - reference: (), - colors: ColorSpaces::default(), - fonts: Remapper::new("F"), - images: Remapper::new("Im"), - deferred_images: HashMap::new(), - gradients: Remapper::new("Gr"), - tilings: None, - ext_gs: Remapper::new("Gs"), - color_fonts: None, - languages: BTreeMap::new(), - glyph_sets: HashMap::new(), - color_glyph_sets: HashMap::new(), - glyph_remappers: HashMap::new(), - } - } -} - -impl Resources<()> { - /// Associate a reference with this resource dictionary (and do so - /// recursively for sub-resources). - pub fn with_refs(self, refs: &ResourcesRefs) -> Resources { - Resources { - reference: refs.reference, - colors: self.colors, - fonts: self.fonts, - images: self.images, - deferred_images: self.deferred_images, - gradients: self.gradients, - tilings: self - .tilings - .zip(refs.tilings.as_ref()) - .map(|(p, r)| Box::new(p.with_refs(r))), - ext_gs: self.ext_gs, - color_fonts: self - .color_fonts - .zip(refs.color_fonts.as_ref()) - .map(|(c, r)| Box::new(c.with_refs(r))), - languages: self.languages, - glyph_sets: self.glyph_sets, - color_glyph_sets: self.color_glyph_sets, - glyph_remappers: self.glyph_remappers, - } - } -} - -impl Resources { - /// Run a function on this resource dictionary and all - /// of its sub-resources. - pub fn traverse

(&self, process: &mut P) -> SourceResult<()> - where - P: FnMut(&Self) -> SourceResult<()>, - { - process(self)?; - if let Some(color_fonts) = &self.color_fonts { - color_fonts.resources.traverse(process)?; - } - if let Some(tilings) = &self.tilings { - tilings.resources.traverse(process)?; - } - Ok(()) - } -} - -/// References for a resource tree. -/// -/// This structure is a tree too, that should have the same structure as the -/// corresponding `Resources`. -pub struct ResourcesRefs { - pub reference: Ref, - pub color_fonts: Option>, - pub tilings: Option>, -} - -impl Renumber for ResourcesRefs { - fn renumber(&mut self, offset: i32) { - self.reference.renumber(offset); - if let Some(color_fonts) = &mut self.color_fonts { - color_fonts.renumber(offset); - } - if let Some(tilings) = &mut self.tilings { - tilings.renumber(offset); - } - } -} - -/// Allocate references for all resource dictionaries. -pub fn alloc_resources_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, ResourcesRefs)> { - let mut chunk = PdfChunk::new(); - /// Recursively explore resource dictionaries and assign them references. - fn refs_for(resources: &Resources<()>, chunk: &mut PdfChunk) -> ResourcesRefs { - ResourcesRefs { - reference: chunk.alloc(), - color_fonts: resources - .color_fonts - .as_ref() - .map(|c| Box::new(refs_for(&c.resources, chunk))), - tilings: resources - .tilings - .as_ref() - .map(|p| Box::new(refs_for(&p.resources, chunk))), - } - } - - let refs = refs_for(&context.resources, &mut chunk); - Ok((chunk, refs)) -} - -/// Write the resource dictionaries that will be referenced by all pages. -/// -/// We add a reference to this dictionary to each page individually instead of -/// to the root node of the page tree because using the resource inheritance -/// feature breaks PDF merging with Apple Preview. -/// -/// Also write resource dictionaries for Type3 fonts and PDF patterns. -pub fn write_resource_dictionaries(ctx: &WithEverything) -> SourceResult<(PdfChunk, ())> { - let mut chunk = PdfChunk::new(); - let mut used_color_spaces = ColorSpaces::default(); - - ctx.resources.traverse(&mut |resources| { - used_color_spaces.merge(&resources.colors); - - let images_ref = chunk.alloc.bump(); - let patterns_ref = chunk.alloc.bump(); - let ext_gs_states_ref = chunk.alloc.bump(); - let color_spaces_ref = chunk.alloc.bump(); - - let mut color_font_slices = Vec::new(); - let mut color_font_numbers = HashMap::new(); - if let Some(color_fonts) = &resources.color_fonts { - for (_, font_slice) in color_fonts.iter() { - color_font_numbers.insert(font_slice.clone(), color_font_slices.len()); - color_font_slices.push(font_slice); - } - } - let color_font_remapper = Remapper { - prefix: "Cf", - to_pdf: color_font_numbers, - to_items: color_font_slices, - }; - - resources - .images - .write(&ctx.references.images, &mut chunk.indirect(images_ref).dict()); - - let mut patterns_dict = chunk.indirect(patterns_ref).dict(); - resources - .gradients - .write(&ctx.references.gradients, &mut patterns_dict); - if let Some(p) = &resources.tilings { - p.remapper.write(&ctx.references.tilings, &mut patterns_dict); - } - patterns_dict.finish(); - - resources - .ext_gs - .write(&ctx.references.ext_gs, &mut chunk.indirect(ext_gs_states_ref).dict()); - - let mut res_dict = chunk - .indirect(resources.reference) - .start::(); - res_dict.pair(Name(b"XObject"), images_ref); - res_dict.pair(Name(b"Pattern"), patterns_ref); - res_dict.pair(Name(b"ExtGState"), ext_gs_states_ref); - res_dict.pair(Name(b"ColorSpace"), color_spaces_ref); - - // TODO: can't this be an indirect reference too? - let mut fonts_dict = res_dict.fonts(); - resources.fonts.write(&ctx.references.fonts, &mut fonts_dict); - color_font_remapper.write(&ctx.references.color_fonts, &mut fonts_dict); - fonts_dict.finish(); - - res_dict.finish(); - - let color_spaces = chunk.indirect(color_spaces_ref).dict(); - resources - .colors - .write_color_spaces(color_spaces, &ctx.globals.color_functions); - - Ok(()) - })?; - - used_color_spaces.write_functions(&mut chunk, &ctx.globals.color_functions); - - Ok((chunk, ())) -} - -/// Assigns new, consecutive PDF-internal indices to items. -pub struct Remapper { - /// The prefix to use when naming these resources. - prefix: &'static str, - /// Forwards from the items to the pdf indices. - to_pdf: HashMap, - /// Backwards from the pdf indices to the items. - to_items: Vec, -} - -impl Remapper -where - T: Eq + Hash + Clone, -{ - /// Create an empty mapping. - pub fn new(prefix: &'static str) -> Self { - Self { prefix, to_pdf: HashMap::new(), to_items: vec![] } - } - - /// Insert an item in the mapping if it was not already present. - pub fn insert(&mut self, item: T) -> usize { - let to_layout = &mut self.to_items; - *self.to_pdf.entry(item.clone()).or_insert_with(|| { - let pdf_index = to_layout.len(); - to_layout.push(item); - pdf_index - }) - } - - /// All items in this - pub fn items(&self) -> impl Iterator + '_ { - self.to_items.iter() - } - - /// Write this list of items in a Resource dictionary. - fn write(&self, mapping: &HashMap, dict: &mut Dict) { - for (number, item) in self.items().enumerate() { - let name = eco_format!("{}{}", self.prefix, number); - let reference = mapping[item]; - dict.pair(Name(name.as_bytes()), reference); - } - } -} diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs new file mode 100644 index 000000000..5b9232dbe --- /dev/null +++ b/crates/typst-pdf/src/shape.rs @@ -0,0 +1,106 @@ +use krilla::geom::{Path, PathBuilder, Rect}; +use krilla::surface::Surface; +use typst_library::diag::SourceResult; +use typst_library::visualize::{Geometry, Shape}; +use typst_syntax::Span; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::paint; +use crate::util::{convert_path, AbsExt, TransformExt}; + +#[typst_macros::time(name = "handle shape")] +pub(crate) fn handle_shape( + fc: &mut FrameContext, + shape: &Shape, + surface: &mut Surface, + gc: &mut GlobalContext, + span: Span, +) -> SourceResult<()> { + surface.set_location(span.into_raw().get()); + surface.push_transform(&fc.state().transform().to_krilla()); + + if let Some(path) = convert_geometry(&shape.geometry) { + let fill = if let Some(paint) = &shape.fill { + Some(paint::convert_fill( + gc, + paint, + shape.fill_rule, + false, + surface, + fc.state(), + shape.geometry.bbox_size(), + )?) + } else { + None + }; + + let stroke = shape.stroke.as_ref().and_then(|stroke| { + if stroke.thickness.to_f32() > 0.0 { + Some(stroke) + } else { + None + } + }); + + let stroke = if let Some(stroke) = &stroke { + let stroke = paint::convert_stroke( + gc, + stroke, + false, + surface, + fc.state(), + shape.geometry.bbox_size(), + )?; + + Some(stroke) + } else { + None + }; + + // Otherwise, krilla will by default fill with a black paint. + if fill.is_some() || stroke.is_some() { + surface.set_fill(fill); + surface.set_stroke(stroke); + surface.draw_path(&path); + } + } + + surface.pop(); + surface.reset_location(); + + Ok(()) +} + +fn convert_geometry(geometry: &Geometry) -> Option { + let mut path_builder = PathBuilder::new(); + + match geometry { + Geometry::Line(l) => { + path_builder.move_to(0.0, 0.0); + path_builder.line_to(l.x.to_f32(), l.y.to_f32()); + } + Geometry::Rect(size) => { + let w = size.x.to_f32(); + let h = size.y.to_f32(); + let rect = if w < 0.0 || h < 0.0 { + // krilla doesn't normally allow for negative dimensions, but + // Typst supports them, so we apply a transform if needed. + let transform = + krilla::geom::Transform::from_scale(w.signum(), h.signum()); + Rect::from_xywh(0.0, 0.0, w.abs(), h.abs()) + .and_then(|rect| rect.transform(transform)) + } else { + Rect::from_xywh(0.0, 0.0, w, h) + }; + + if let Some(rect) = rect { + path_builder.push_rect(rect); + } + } + Geometry::Curve(c) => { + convert_path(c, &mut path_builder); + } + } + + path_builder.finish() +} diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs new file mode 100644 index 000000000..8d532e08c --- /dev/null +++ b/crates/typst-pdf/src/text.rs @@ -0,0 +1,135 @@ +use std::ops::Range; +use std::sync::Arc; + +use bytemuck::TransparentWrapper; +use krilla::surface::{Location, Surface}; +use krilla::text::GlyphId; +use typst_library::diag::{bail, SourceResult}; +use typst_library::layout::Size; +use typst_library::text::{Font, Glyph, TextItem}; +use typst_library::visualize::FillRule; +use typst_syntax::Span; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::paint; +use crate::util::{display_font, AbsExt, TransformExt}; + +#[typst_macros::time(name = "handle text")] +pub(crate) fn handle_text( + fc: &mut FrameContext, + t: &TextItem, + surface: &mut Surface, + gc: &mut GlobalContext, +) -> SourceResult<()> { + *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); + + let font = convert_font(gc, t.font.clone())?; + let fill = paint::convert_fill( + gc, + &t.fill, + FillRule::NonZero, + true, + surface, + fc.state(), + Size::zero(), + )?; + let stroke = + if let Some(stroke) = t.stroke.as_ref().map(|s| { + paint::convert_stroke(gc, s, true, surface, fc.state(), Size::zero()) + }) { + Some(stroke?) + } else { + None + }; + let text = t.text.as_str(); + let size = t.size; + let glyphs: &[PdfGlyph] = TransparentWrapper::wrap_slice(t.glyphs.as_slice()); + + surface.push_transform(&fc.state().transform().to_krilla()); + surface.set_fill(Some(fill)); + surface.set_stroke(stroke); + surface.draw_glyphs( + krilla::geom::Point::from_xy(0.0, 0.0), + glyphs, + font.clone(), + text, + size.to_f32(), + false, + ); + + surface.pop(); + + Ok(()) +} + +fn convert_font( + gc: &mut GlobalContext, + typst_font: Font, +) -> SourceResult { + if let Some(font) = gc.fonts_forward.get(&typst_font) { + Ok(font.clone()) + } else { + let font = build_font(typst_font.clone())?; + + gc.fonts_forward.insert(typst_font.clone(), font.clone()); + gc.fonts_backward.insert(font.clone(), typst_font.clone()); + + Ok(font) + } +} + +#[comemo::memoize] +fn build_font(typst_font: Font) -> SourceResult { + let font_data: Arc + Send + Sync> = + Arc::new(typst_font.data().clone()); + + match krilla::text::Font::new(font_data.into(), typst_font.index()) { + None => { + let font_str = display_font(&typst_font); + bail!(Span::detached(), "failed to process font {font_str}"); + } + Some(f) => Ok(f), + } +} + +#[derive(TransparentWrapper, Debug)] +#[repr(transparent)] +struct PdfGlyph(Glyph); + +impl krilla::text::Glyph for PdfGlyph { + #[inline(always)] + fn glyph_id(&self) -> GlyphId { + GlyphId::new(self.0.id as u32) + } + + #[inline(always)] + fn text_range(&self) -> Range { + self.0.range.start as usize..self.0.range.end as usize + } + + #[inline(always)] + fn x_advance(&self, size: f32) -> f32 { + // Don't use `Em::at`, because it contains an expensive check whether the result is finite. + self.0.x_advance.get() as f32 * size + } + + #[inline(always)] + fn x_offset(&self, size: f32) -> f32 { + // Don't use `Em::at`, because it contains an expensive check whether the result is finite. + self.0.x_offset.get() as f32 * size + } + + #[inline(always)] + fn y_offset(&self, _: f32) -> f32 { + 0.0 + } + + #[inline(always)] + fn y_advance(&self, _: f32) -> f32 { + 0.0 + } + + fn location(&self) -> Option { + Some(self.0.span.0.into_raw().get()) + } +} diff --git a/crates/typst-pdf/src/tiling.rs b/crates/typst-pdf/src/tiling.rs deleted file mode 100644 index f8950f344..000000000 --- a/crates/typst-pdf/src/tiling.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::collections::HashMap; - -use ecow::eco_format; -use pdf_writer::types::{ColorSpaceOperand, PaintType, TilingType}; -use pdf_writer::{Filter, Name, Rect, Ref}; -use typst_library::diag::SourceResult; -use typst_library::layout::{Abs, Ratio, Transform}; -use typst_library::visualize::{RelativeTo, Tiling}; -use typst_utils::Numeric; - -use crate::color::PaintEncode; -use crate::resources::{Remapper, ResourcesRefs}; -use crate::{content, transform_to_array, PdfChunk, Resources, WithGlobalRefs}; - -/// Writes the actual patterns (tiling patterns) to the PDF. -/// This is performed once after writing all pages. -pub fn write_tilings( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - let Some(patterns) = &resources.tilings else { - return Ok(()); - }; - - for pdf_pattern in patterns.remapper.items() { - let PdfTiling { transform, pattern, content, .. } = pdf_pattern; - if out.contains_key(pdf_pattern) { - continue; - } - - let tiling = chunk.alloc(); - out.insert(pdf_pattern.clone(), tiling); - - let mut tiling_pattern = chunk.tiling_pattern(tiling, content); - tiling_pattern - .tiling_type(TilingType::ConstantSpacing) - .paint_type(PaintType::Colored) - .bbox(Rect::new( - 0.0, - 0.0, - pattern.size().x.to_pt() as _, - pattern.size().y.to_pt() as _, - )) - .x_step((pattern.size().x + pattern.spacing().x).to_pt() as _) - .y_step((pattern.size().y + pattern.spacing().y).to_pt() as _); - - // The actual resource dict will be written in a later step - tiling_pattern.pair(Name(b"Resources"), patterns.resources.reference); - - tiling_pattern - .matrix(transform_to_array( - transform - .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())) - .post_concat(Transform::translate( - Abs::zero(), - pattern.spacing().y, - )), - )) - .filter(Filter::FlateDecode); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// A pattern and its transform. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -pub struct PdfTiling { - /// The transform to apply to the pattern. - pub transform: Transform, - /// The pattern to paint. - pub pattern: Tiling, - /// The rendered pattern. - pub content: Vec, -} - -/// Registers a pattern with the PDF. -fn register_pattern( - ctx: &mut content::Builder, - pattern: &Tiling, - on_text: bool, - mut transforms: content::Transforms, -) -> SourceResult { - let patterns = ctx - .resources - .tilings - .get_or_insert_with(|| Box::new(TilingRemapper::new())); - - // Edge cases for strokes. - if transforms.size.x.is_zero() { - transforms.size.x = Abs::pt(1.0); - } - - if transforms.size.y.is_zero() { - transforms.size.y = Abs::pt(1.0); - } - - let transform = match pattern.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.transform, - RelativeTo::Parent => transforms.container_transform, - }; - - // Render the body. - let content = content::build( - ctx.options, - &mut patterns.resources, - pattern.frame(), - None, - None, - )?; - - let pdf_pattern = PdfTiling { - transform, - pattern: pattern.clone(), - content: content.content.wait().clone(), - }; - - Ok(patterns.remapper.insert(pdf_pattern)) -} - -impl PaintEncode for Tiling { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_fill_color_space(); - - let index = register_pattern(ctx, self, on_text, transforms)?; - let id = eco_format!("P{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_fill_pattern(None, name); - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_stroke_color_space(); - - let index = register_pattern(ctx, self, on_text, transforms)?; - let id = eco_format!("P{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_stroke_pattern(None, name); - Ok(()) - } -} - -/// De-duplicate patterns and the resources they require to be drawn. -pub struct TilingRemapper { - /// Pattern de-duplicator. - pub remapper: Remapper, - /// PDF resources that are used by these patterns. - pub resources: Resources, -} - -impl TilingRemapper<()> { - pub fn new() -> Self { - Self { - remapper: Remapper::new("P"), - resources: Resources::default(), - } - } - - /// Allocate a reference to the resource dictionary of these patterns. - pub fn with_refs(self, refs: &ResourcesRefs) -> TilingRemapper { - TilingRemapper { - remapper: self.remapper, - resources: self.resources.with_refs(refs), - } - } -} diff --git a/crates/typst-pdf/src/util.rs b/crates/typst-pdf/src/util.rs new file mode 100644 index 000000000..3b85d0b8a --- /dev/null +++ b/crates/typst-pdf/src/util.rs @@ -0,0 +1,120 @@ +//! Basic utilities for converting typst types to krilla. + +use krilla::geom as kg; +use krilla::geom::PathBuilder; +use krilla::paint as kp; +use typst_library::layout::{Abs, Point, Size, Transform}; +use typst_library::text::Font; +use typst_library::visualize::{Curve, CurveItem, FillRule, LineCap, LineJoin}; + +pub(crate) trait SizeExt { + fn to_krilla(&self) -> kg::Size; +} + +impl SizeExt for Size { + fn to_krilla(&self) -> kg::Size { + kg::Size::from_wh(self.x.to_f32(), self.y.to_f32()).unwrap() + } +} + +pub(crate) trait PointExt { + fn to_krilla(&self) -> kg::Point; +} + +impl PointExt for Point { + fn to_krilla(&self) -> kg::Point { + kg::Point::from_xy(self.x.to_f32(), self.y.to_f32()) + } +} + +pub(crate) trait LineCapExt { + fn to_krilla(&self) -> kp::LineCap; +} + +impl LineCapExt for LineCap { + fn to_krilla(&self) -> kp::LineCap { + match self { + LineCap::Butt => kp::LineCap::Butt, + LineCap::Round => kp::LineCap::Round, + LineCap::Square => kp::LineCap::Square, + } + } +} + +pub(crate) trait LineJoinExt { + fn to_krilla(&self) -> kp::LineJoin; +} + +impl LineJoinExt for LineJoin { + fn to_krilla(&self) -> kp::LineJoin { + match self { + LineJoin::Miter => kp::LineJoin::Miter, + LineJoin::Round => kp::LineJoin::Round, + LineJoin::Bevel => kp::LineJoin::Bevel, + } + } +} + +pub(crate) trait TransformExt { + fn to_krilla(&self) -> kg::Transform; +} + +impl TransformExt for Transform { + fn to_krilla(&self) -> kg::Transform { + kg::Transform::from_row( + self.sx.get() as f32, + self.ky.get() as f32, + self.kx.get() as f32, + self.sy.get() as f32, + self.tx.to_f32(), + self.ty.to_f32(), + ) + } +} + +pub(crate) trait FillRuleExt { + fn to_krilla(&self) -> kp::FillRule; +} + +impl FillRuleExt for FillRule { + fn to_krilla(&self) -> kp::FillRule { + match self { + FillRule::NonZero => kp::FillRule::NonZero, + FillRule::EvenOdd => kp::FillRule::EvenOdd, + } + } +} + +pub(crate) trait AbsExt { + fn to_f32(self) -> f32; +} + +impl AbsExt for Abs { + fn to_f32(self) -> f32 { + self.to_pt() as f32 + } +} + +/// Display the font family of a font. +pub(crate) fn display_font(font: &Font) -> &str { + &font.info().family +} + +/// Convert a typst path to a krilla path. +pub(crate) fn convert_path(path: &Curve, builder: &mut PathBuilder) { + for item in &path.0 { + match item { + CurveItem::Move(p) => builder.move_to(p.x.to_f32(), p.y.to_f32()), + CurveItem::Line(p) => builder.line_to(p.x.to_f32(), p.y.to_f32()), + CurveItem::Cubic(p1, p2, p3) => builder.cubic_to( + p1.x.to_f32(), + p1.y.to_f32(), + p2.x.to_f32(), + p2.y.to_f32(), + p3.x.to_f32(), + p3.y.to_f32(), + ), + CurveItem::Close => builder.close(), + } + } +} From 12699eb7f415bdba6797c84e3e7bf44dde75bdf9 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Wed, 2 Apr 2025 05:30:04 -0400 Subject: [PATCH 13/31] Parse multi-character numbers consistently in math (#5996) Co-authored-by: Laurenz --- crates/typst-syntax/src/parser.rs | 8 +++----- .../ref/issue-4828-math-number-multi-char.png | Bin 0 -> 465 bytes tests/ref/math-frac-precedence.png | Bin 5504 -> 3586 bytes tests/suite/math/frac.typ | 4 ++-- tests/suite/math/syntax.typ | 4 ++++ 5 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 tests/ref/issue-4828-math-number-multi-char.png diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index c5d13c8b3..ecd0d78a5 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -271,11 +271,9 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { } SyntaxKind::Text | SyntaxKind::MathText | SyntaxKind::MathShorthand => { - continuable = !p.at(SyntaxKind::MathShorthand) - && matches!( - math_class(p.current_text()), - None | Some(MathClass::Alphabetic) - ); + // `a(b)/c` parses as `(a(b))/c` if `a` is continuable. + continuable = math_class(p.current_text()) == Some(MathClass::Alphabetic) + || p.current_text().chars().all(char::is_alphabetic); if !maybe_delimited(p) { p.eat(); } diff --git a/tests/ref/issue-4828-math-number-multi-char.png b/tests/ref/issue-4828-math-number-multi-char.png new file mode 100644 index 0000000000000000000000000000000000000000..ff0a9bab97de1957f3ea99de261346c14f8ccd9d GIT binary patch literal 465 zcmV;?0WSWDP)|ER(H<~| zY15fS>tQtA{}XtTVCdm*c-i;*JA9768pl*k6|TZn_hfVZBb`zLU zu}s@P^Y-#l!KFB0edr+gWfqVu9ueHV4bU7M05<^?+C#&I=3AT8;l;r86B6U-X$jKF za=L2`P;6~j!>cYf`lp^vQWqM|=kr@!WGr_t9x0mZz~X6-bI-fy7r^iQUO)9tl5%1- z#^oR^9F0bMsV8vm3s&yKnOscn)a^gub#$+3kwt>F^Kh*g7B-VXEpJLEO)(aS^x2H5 z;KpF747*FrtuipA^fu91SfEx|IQ_Y;k4A3B?xZqAmSN`-9qo4u4+mkcQ8L^4%lZSK z8oHWdWg0`nTS2m)v3YDC_`}G^hC`YiT`~W@dW%)K3RmbaR+3c8Kj6b400000NkvXX Hu0mjf!|2wg literal 0 HcmV?d00001 diff --git a/tests/ref/math-frac-precedence.png b/tests/ref/math-frac-precedence.png index 973c433e2c0d267aa57d746e586736c2b77b1a96..fd16f2e6bf3e043a81e4e6e3146a6b15c05fc559 100644 GIT binary patch literal 3586 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 IM6N<$f*h$L1poj5 literal 5504 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 diff --git a/tests/suite/math/frac.typ b/tests/suite/math/frac.typ index 7f513930a..3bd00eab2 100644 --- a/tests/suite/math/frac.typ +++ b/tests/suite/math/frac.typ @@ -37,8 +37,8 @@ $ 1/2/3 = (1/2)/3 = 1/(2/3) $ // Test precedence. $ a_1/b_2, 1/f(x), zeta(x)/2, "foo"[|x|]/2 \ 1.2/3.7, 2.3^3.4 \ - 🏳️‍🌈[x]/2, f [x]/2, phi [x]/2, 🏳️‍🌈 [x]/2 \ - +[x]/2, 1(x)/2, 2[x]/2 \ + f [x]/2, phi [x]/2 \ + +[x]/2, 1(x)/2, 2[x]/2, 🏳️‍🌈[x]/2 \ (a)b/2, b(a)[b]/2 \ n!/2, 5!/2, n !/2, 1/n!, 1/5! $ diff --git a/tests/suite/math/syntax.typ b/tests/suite/math/syntax.typ index 7091d908c..32b9c098c 100644 --- a/tests/suite/math/syntax.typ +++ b/tests/suite/math/syntax.typ @@ -28,6 +28,10 @@ $ dot \ dots \ ast \ tilde \ star $ $floor(phi.alt.)$ $floor(phi.alt. )$ +--- issue-4828-math-number-multi-char --- +// Numbers should parse the same regardless of number of characters. +$1/2(x)$ vs. $1/10(x)$ + --- math-unclosed --- // Error: 1-2 unclosed delimiter $a From 417f5846b68777b8a4d3b9336761bd23c48a26b5 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 2 Apr 2025 11:41:45 +0200 Subject: [PATCH 14/31] Support comparison functions in `array.sorted` (#5627) Co-authored-by: +merlan #flirora Co-authored-by: Laurenz --- Cargo.lock | 7 + Cargo.toml | 1 + crates/typst-library/Cargo.toml | 1 + crates/typst-library/src/foundations/array.rs | 149 +++++++++++++++--- tests/suite/foundations/array.typ | 6 + 5 files changed, 140 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c13c64819..f9c0cb189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "half" version = "2.4.1" @@ -3052,6 +3058,7 @@ dependencies = [ "ecow", "flate2", "fontdb", + "glidesort", "hayagriva", "icu_properties", "icu_provider", diff --git a/Cargo.toml b/Cargo.toml index cbe69a05d..b9ec25054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ fastrand = "2.3" flate2 = "1" fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" +glidesort = "0.1.2" hayagriva = "0.8.1" heck = "0.5" hypher = "0.1.4" diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index 71729b63a..b210637a8 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -29,6 +29,7 @@ csv = { workspace = true } ecow = { workspace = true } flate2 = { workspace = true } fontdb = { workspace = true } +glidesort = { workspace = true } hayagriva = { workspace = true } icu_properties = { workspace = true } icu_provider = { workspace = true } diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index b647473ab..c1fcb6b49 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -808,7 +808,7 @@ impl Array { /// function. The sorting algorithm used is stable. /// /// Returns an error if two values could not be compared or if the key - /// function (if given) yields an error. + /// or comparison function (if given) yields an error. /// /// To sort according to multiple criteria at once, e.g. in case of equality /// between some criteria, the key function can return an array. The results @@ -832,33 +832,134 @@ impl Array { /// determine the keys to sort by. #[named] key: Option, + /// If given, uses this function to compare elements in the array. + /// + /// This function should return a boolean: `{true}` indicates that the + /// elements are in order, while `{false}` indicates that they should be + /// swapped. To keep the sort stable, if the two elements are equal, the + /// function should return `{true}`. + /// + /// If this function does not order the elements properly (e.g., by + /// returning `{false}` for both `{(x, y)}` and `{(y, x)}`, or for + /// `{(x, x)}`), the resulting array will be in unspecified order. + /// + /// When used together with `key`, `by` will be passed the keys instead + /// of the elements. + /// + /// ```example + /// #( + /// "sorted", + /// "by", + /// "decreasing", + /// "length", + /// ).sorted( + /// key: s => s.len(), + /// by: (l, r) => l >= r, + /// ) + /// ``` + #[named] + by: Option, ) -> SourceResult { - let mut result = Ok(()); - let mut vec = self.0; - let mut key_of = |x: Value| match &key { - // NOTE: We are relying on `comemo`'s memoization of function - // evaluation to not excessively reevaluate the `key`. - Some(f) => f.call(engine, context, [x]), - None => Ok(x), - }; - vec.make_mut().sort_by(|a, b| { - // Until we get `try` blocks :) - match (key_of(a.clone()), key_of(b.clone())) { - (Ok(a), Ok(b)) => ops::compare(&a, &b).unwrap_or_else(|err| { - if result.is_ok() { - result = Err(err).at(span); + match by { + Some(by) => { + let mut are_in_order = |mut x, mut y| { + if let Some(f) = &key { + // We rely on `comemo`'s memoization of function + // evaluation to not excessively reevaluate the key. + x = f.call(engine, context, [x])?; + y = f.call(engine, context, [y])?; } - Ordering::Equal - }), - (Err(e), _) | (_, Err(e)) => { - if result.is_ok() { - result = Err(e); + match by.call(engine, context, [x, y])? { + Value::Bool(b) => Ok(b), + x => { + bail!( + span, + "expected boolean from `by` function, got {}", + x.ty(), + ) + } } - Ordering::Equal - } + }; + // If a comparison function is provided, we use `glidesort` + // instead of the standard library sorting algorithm to prevent + // panics in case the comparison function does not define a + // valid order (see https://github.com/typst/typst/pull/5627). + let mut result = Ok(()); + let mut vec = self.0.into_iter().enumerate().collect::>(); + glidesort::sort_by(&mut vec, |(i, x), (j, y)| { + // Because we use booleans for the comparison function, in + // order to keep the sort stable, we need to compare in the + // right order. + if i < j { + // If `x` and `y` appear in this order in the original + // array, then we should change their order (i.e., + // return `Ordering::Greater`) iff `y` is strictly less + // than `x` (i.e., `compare(x, y)` returns `false`). + // Otherwise, we should keep them in the same order + // (i.e., return `Ordering::Less`). + match are_in_order(x.clone(), y.clone()) { + Ok(false) => Ordering::Greater, + Ok(true) => Ordering::Less, + Err(err) => { + if result.is_ok() { + result = Err(err); + } + Ordering::Equal + } + } + } else { + // If `x` and `y` appear in the opposite order in the + // original array, then we should change their order + // (i.e., return `Ordering::Less`) iff `x` is strictly + // less than `y` (i.e., `compare(y, x)` returns + // `false`). Otherwise, we should keep them in the same + // order (i.e., return `Ordering::Less`). + match are_in_order(y.clone(), x.clone()) { + Ok(false) => Ordering::Less, + Ok(true) => Ordering::Greater, + Err(err) => { + if result.is_ok() { + result = Err(err); + } + Ordering::Equal + } + } + } + }); + result.map(|()| vec.into_iter().map(|(_, x)| x).collect()) } - }); - result.map(|_| vec.into()) + + None => { + let mut key_of = |x: Value| match &key { + // We rely on `comemo`'s memoization of function evaluation + // to not excessively reevaluate the key. + Some(f) => f.call(engine, context, [x]), + None => Ok(x), + }; + // If no comparison function is provided, we know the order is + // valid, so we can use the standard library sort and prevent an + // extra allocation. + let mut result = Ok(()); + let mut vec = self.0; + vec.make_mut().sort_by(|a, b| { + match (key_of(a.clone()), key_of(b.clone())) { + (Ok(a), Ok(b)) => ops::compare(&a, &b).unwrap_or_else(|err| { + if result.is_ok() { + result = Err(err).at(span); + } + Ordering::Equal + }), + (Err(e), _) | (_, Err(e)) => { + if result.is_ok() { + result = Err(e); + } + Ordering::Equal + } + } + }); + result.map(|()| vec.into()) + } + } } /// Deduplicates all items in the array. diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ index 61b5decb3..0c820d7f2 100644 --- a/tests/suite/foundations/array.typ +++ b/tests/suite/foundations/array.typ @@ -359,6 +359,12 @@ #test((2, 1, 3, 10, 5, 8, 6, -7, 2).sorted(), (-7, 1, 2, 2, 3, 5, 6, 8, 10)) #test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x), (-10, -7, -5, 1, 2, 2, 3, 6, 8)) #test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x * x), (1, 2, 2, 3, -5, 6, -7, 8, -10)) +#test(("I", "the", "hi", "text").sorted(by: (x, y) => x.len() < y.len()), ("I", "hi", "the", "text")) +#test(("I", "the", "hi", "text").sorted(key: x => x.len(), by: (x, y) => y < x), ("text", "the", "hi", "I")) + +--- array-sorted-invalid-by-function --- +// Error: 2-39 expected boolean from `by` function, got string +#(1, 2, 3).sorted(by: (_, _) => "hmm") --- array-sorted-key-function-positional-1 --- // Error: 12-18 unexpected argument From ed2106e28d4b0cc213a4789d5e59c59ad08e9f29 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:47:42 +0200 Subject: [PATCH 15/31] Disallow empty font lists (#6049) --- crates/typst-library/src/text/mod.rs | 16 ++++++++++++++-- tests/suite/text/font.typ | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 3aac15ba5..462d16060 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -42,7 +42,7 @@ use ttf_parser::Tag; use typst_syntax::Spanned; use typst_utils::singleton; -use crate::diag::{bail, warning, HintedStrResult, SourceResult}; +use crate::diag::{bail, warning, HintedStrResult, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ cast, dict, elem, Args, Array, Cast, Construct, Content, Dict, Fold, IntoValue, @@ -891,9 +891,21 @@ cast! { } /// Font family fallback list. +/// +/// Must contain at least one font. #[derive(Debug, Default, Clone, PartialEq, Hash)] pub struct FontList(pub Vec); +impl FontList { + pub fn new(fonts: Vec) -> StrResult { + if fonts.is_empty() { + bail!("font fallback list must not be empty") + } else { + Ok(Self(fonts)) + } + } +} + impl<'a> IntoIterator for &'a FontList { type IntoIter = std::slice::Iter<'a, FontFamily>; type Item = &'a FontFamily; @@ -911,7 +923,7 @@ cast! { self.0.into_value() }, family: FontFamily => Self(vec![family]), - values: Array => Self(values.into_iter().map(|v| v.cast()).collect::>()?), + values: Array => Self::new(values.into_iter().map(|v| v.cast()).collect::>()?)?, } /// Resolve a prioritized iterator over the font families. diff --git a/tests/suite/text/font.typ b/tests/suite/text/font.typ index 60a1cd94d..6e21dfd23 100644 --- a/tests/suite/text/font.typ +++ b/tests/suite/text/font.typ @@ -149,3 +149,7 @@ The number 123. #set text(-1pt) a + +--- empty-text-font-array --- +// Error: 17-19 font fallback list must not be empty +#set text(font: ()) From bf8751c06352c305a8132a2bd0a06ced557a3819 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 4 Apr 2025 10:35:51 +0200 Subject: [PATCH 16/31] Switch to released `krilla` version (#6137) --- Cargo.lock | 37 +++++++++++++++++++++---------------- Cargo.toml | 6 ++---- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c0cb189..8c485ea7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,9 +787,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d868ec188a98bb014c606072edd47e52e7ab7297db943b0b28503121e1d037bd" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" dependencies = [ "bytemuck", ] @@ -1360,8 +1360,9 @@ dependencies = [ [[package]] name = "krilla" -version = "0.3.0" -source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" dependencies = [ "base64", "bumpalo", @@ -1388,8 +1389,9 @@ dependencies = [ [[package]] name = "krilla-svg" -version = "0.3.0" -source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" dependencies = [ "flate2", "fontdb", @@ -1837,8 +1839,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" -version = "0.12.1" -source = "git+https://github.com/typst/pdf-writer?rev=0d513b9#0d513b9050d2f1a0507cabb4898aca971af6da98" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea27c5015ab81753fc61e49f8cde74999346605ee148bb20008ef3d3150e0dc" dependencies = [ "bitflags 2.8.0", "itoa", @@ -2097,9 +2100,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f004ee5c610b8beb5f33273246893ac6258ec22185a6eb8ee132bccdb904cdaa" +checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" dependencies = [ "bytemuck", "font-types", @@ -2425,9 +2428,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.28.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e7936ca3627fdb516e97aca3c8ab5103f94ae32fe5ce80a0a02edcbacb7b53" +checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" dependencies = [ "bytemuck", "read-fonts", @@ -2522,8 +2525,9 @@ dependencies = [ [[package]] name = "subsetter" -version = "0.2.0" -source = "git+https://github.com/typst/subsetter?rev=460fdb6#460fdb66d6e0138b721b1ca9882faf15ce003246" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35539e8de3dcce8dd0c01f3575f85db1e5ac1aea1b996d2d09d89f148bc91497" dependencies = [ "fxhash", ] @@ -3755,8 +3759,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xmp-writer" -version = "0.3.1" -source = "git+https://github.com/LaurenzV/xmp-writer?rev=a1cbb887#a1cbb887a84376fea4d7590d41c194a332840549" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9e2f4a404d9ebffc0a9832cf4f50907220ba3d7fffa9099261a5cab52f2dd7" [[package]] name = "xz2" diff --git a/Cargo.toml b/Cargo.toml index b9ec25054..16c6a7d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,8 +72,8 @@ if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.6" -krilla = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7", default-features = false, features = ["raster-images", "comemo", "rayon"] } -krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7" } +krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = "0.1.0" kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" @@ -87,7 +87,6 @@ oxipng = { version = "9.0", default-features = false, features = ["filetime", "p palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" pathdiff = "0.2" -pdf-writer = "0.12.1" phf = { version = "0.11", features = ["macros"] } pixglyph = "0.6" png = "0.17" @@ -114,7 +113,6 @@ sigpipe = "0.1" siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" -subsetter = "0.2" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" From 387a8b48951b0e7e283c81557852e3eba3afb446 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Fri, 4 Apr 2025 13:53:14 +0200 Subject: [PATCH 17/31] Display color spaces in the order in which they are presented in the doc (#6140) --- crates/typst-library/src/visualize/gradient.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index d59175a4e..45f388ccd 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -120,12 +120,12 @@ use crate::visualize::{Color, ColorSpace, WeightedColor}; /// #let spaces = ( /// ("Oklab", color.oklab), /// ("Oklch", color.oklch), -/// ("linear-RGB", color.linear-rgb), /// ("sRGB", color.rgb), +/// ("linear-RGB", color.linear-rgb), /// ("CMYK", color.cmyk), +/// ("Grayscale", color.luma), /// ("HSL", color.hsl), /// ("HSV", color.hsv), -/// ("Grayscale", color.luma), /// ) /// /// #for (name, space) in spaces { From ea336a6ac71ba9d84da6caa5d64291c87b0bca44 Mon Sep 17 00:00:00 2001 From: Markus Langgeng Iman Saputra Date: Fri, 4 Apr 2025 15:50:13 +0000 Subject: [PATCH 18/31] Add Indonesian translation (#6108) Co-authored-by: Malo <57839069+MDLC01@users.noreply.github.com> --- crates/typst-library/src/text/lang.rs | 4 +++- crates/typst-library/translations/id.txt | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 crates/typst-library/translations/id.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index c75e5225f..2cc66a261 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -14,7 +14,7 @@ macro_rules! translation { }; } -const TRANSLATIONS: [(&str, &str); 38] = [ +const TRANSLATIONS: [(&str, &str); 39] = [ translation!("ar"), translation!("bg"), translation!("ca"), @@ -31,6 +31,7 @@ const TRANSLATIONS: [(&str, &str); 38] = [ translation!("el"), translation!("he"), translation!("hu"), + translation!("id"), translation!("is"), translation!("it"), translation!("ja"), @@ -82,6 +83,7 @@ impl Lang { pub const HEBREW: Self = Self(*b"he ", 2); pub const HUNGARIAN: Self = Self(*b"hu ", 2); pub const ICELANDIC: Self = Self(*b"is ", 2); + pub const INDONESIAN: Self = Self(*b"id ", 2); pub const ITALIAN: Self = Self(*b"it ", 2); pub const JAPANESE: Self = Self(*b"ja ", 2); pub const LATIN: Self = Self(*b"la ", 2); diff --git a/crates/typst-library/translations/id.txt b/crates/typst-library/translations/id.txt new file mode 100644 index 000000000..bea5ee18c --- /dev/null +++ b/crates/typst-library/translations/id.txt @@ -0,0 +1,8 @@ +figure = Gambar +table = Tabel +equation = Persamaan +bibliography = Daftar Pustaka +heading = Bagian +outline = Daftar Isi +raw = Kode +page = halaman From d55abf084263c15b4eac8efcf4faa3aafdd3af11 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 7 Apr 2025 19:46:46 +0200 Subject: [PATCH 19/31] Update community section in README (#6150) --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 41f465152..9526f3df4 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Typst's CLI is available from different sources: - You can install Typst through different package managers. Note that the versions in the package managers might lag behind the latest release. - - Linux: + - Linux: - View [Typst on Repology][repology] - View [Typst's Snap][snap] - macOS: `brew install typst` @@ -177,22 +177,22 @@ If you prefer an integrated IDE-like experience with autocompletion and instant preview, you can also check out [Typst's free web app][app]. ## Community -The main place where the community gathers is our [Discord server][discord]. -Feel free to join there to ask questions, help out others, share cool things -you created with Typst, or just to chat. +The main places where the community gathers are our [Forum][forum] and our +[Discord server][discord]. The Forum is a great place to ask questions, help +others, and share cool things you created with Typst. The Discord server is more +suitable for quicker questions, discussions about contributing, or just to chat. +We'd be happy to see you there! -Aside from that there are a few places where you can find things built by -the community: - -- The official [package list](https://typst.app/docs/packages) -- The [Awesome Typst](https://github.com/qjcg/awesome-typst) repository +[Typst Universe][universe] is where the community shares templates and packages. +If you want to share your own creations, you can submit them to our +[package repository][packages]. If you had a bad experience in our community, please [reach out to us][contact]. ## Contributing -We would love to see contributions from the community. If you experience bugs, -feel free to open an issue. If you would like to implement a new feature or bug -fix, please follow the steps outlined in the [contribution guide][contributing]. +We love to see contributions from the community. If you experience bugs, feel +free to open an issue. If you would like to implement a new feature or bug fix, +please follow the steps outlined in the [contribution guide][contributing]. To build Typst yourself, first ensure that you have the [latest stable Rust][rust] installed. Then, clone this repository and build the @@ -243,6 +243,8 @@ instant preview. To achieve these goals, we follow three core design principles: [docs]: https://typst.app/docs/ [app]: https://typst.app/ [discord]: https://discord.gg/2uDybryKPe +[forum]: https://forum.typst.app/ +[universe]: https://typst.app/universe/ [tutorial]: https://typst.app/docs/tutorial/ [show]: https://typst.app/docs/reference/styling/#show-rules [math]: https://typst.app/docs/reference/math/ From 14928ef9628d084af370463ccbf2f3bae3f70794 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:47:29 +0300 Subject: [PATCH 20/31] Fix typo in module docs (#6146) Co-authored-by: Alberto Corbi --- crates/typst-library/src/foundations/module.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index 8d9626a1a..d6d5e831d 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -7,7 +7,7 @@ use typst_syntax::FileId; use crate::diag::{bail, DeprecationSink, StrResult}; use crate::foundations::{repr, ty, Content, Scope, Value}; -/// An module of definitions. +/// A module of definitions. /// /// A module /// - be built-in From bd2e76e11d487d1e825217db155e45d3fb6f6584 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 7 Apr 2025 20:20:27 +0200 Subject: [PATCH 21/31] Bump OpenSSL (#6153) --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c485ea7d..ab2d2cc83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,9 +1702,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.70" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -1743,9 +1743,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.105" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 16c6a7d63..12870b809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ native-tls = "0.2" notify = "8" once_cell = "1" open = "5.0.1" -openssl = "0.10" +openssl = "0.10.72" oxipng = { version = "9.0", default-features = false, features = ["filetime", "parallel", "zopfli"] } palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" From 14a0565d95b40bb58a07da554b7d05d4712efcd7 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 7 Apr 2025 14:42:29 -0400 Subject: [PATCH 22/31] Show warnings from eval (#6100) Co-authored-by: Laurenz --- crates/typst-cli/src/query.rs | 3 +++ crates/typst-eval/src/lib.rs | 4 ++-- crates/typst-library/src/foundations/mod.rs | 12 +++++++++++- crates/typst-library/src/model/bibliography.rs | 6 ++++-- crates/typst-library/src/routines.rs | 1 + tests/suite/foundations/eval.typ | 6 ++++++ 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 610f23cd4..7806e456f 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -2,6 +2,7 @@ use comemo::Track; use ecow::{eco_format, EcoString}; use serde::Serialize; use typst::diag::{bail, HintedStrResult, StrResult, Warned}; +use typst::engine::Sink; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; use typst::layout::PagedDocument; use typst::syntax::Span; @@ -58,6 +59,8 @@ fn retrieve( let selector = eval_string( &typst::ROUTINES, world.track(), + // TODO: propagate warnings + Sink::new().track_mut(), &command.selector, Span::detached(), EvalMode::Code, diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 5eae7c1df..586da26be 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -101,6 +101,7 @@ pub fn eval( pub fn eval_string( routines: &Routines, world: Tracked, + sink: TrackedMut, string: &str, span: Span, mode: EvalMode, @@ -121,7 +122,6 @@ pub fn eval_string( } // Prepare the engine. - let mut sink = Sink::new(); let introspector = Introspector::default(); let traced = Traced::default(); let engine = Engine { @@ -129,7 +129,7 @@ pub fn eval_string( world, introspector: introspector.track(), traced: traced.track(), - sink: sink.track_mut(), + sink, route: Route::default(), }; diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index 8e3aa060d..d42be15b1 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -77,6 +77,7 @@ pub use { indexmap::IndexMap, }; +use comemo::TrackedMut; use ecow::EcoString; use typst_syntax::Spanned; @@ -297,5 +298,14 @@ pub fn eval( for (key, value) in dict { scope.bind(key.into(), Binding::new(value, span)); } - (engine.routines.eval_string)(engine.routines, engine.world, &text, span, mode, scope) + + (engine.routines.eval_string)( + engine.routines, + engine.world, + TrackedMut::reborrow_mut(&mut engine.sink), + &text, + span, + mode, + scope, + ) } diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index b11c61789..51e3b03b0 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -6,7 +6,7 @@ use std::num::NonZeroUsize; use std::path::Path; use std::sync::{Arc, LazyLock}; -use comemo::Tracked; +use comemo::{Track, Tracked}; use ecow::{eco_format, EcoString, EcoVec}; use hayagriva::archive::ArchivedStyle; use hayagriva::io::BibLaTeXError; @@ -20,7 +20,7 @@ use typst_syntax::{Span, Spanned}; use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr}; use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult}; -use crate::engine::Engine; +use crate::engine::{Engine, Sink}; use crate::foundations::{ elem, Bytes, CastInfo, Content, Derived, FromValue, IntoValue, Label, NativeElement, OneOrMultiple, Packed, Reflect, Scope, Show, ShowSet, Smart, StyleChain, Styles, @@ -999,6 +999,8 @@ impl ElemRenderer<'_> { (self.routines.eval_string)( self.routines, self.world, + // TODO: propagate warnings + Sink::new().track_mut(), math, self.span, EvalMode::Math, diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index b283052a4..6f0cb32b1 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -55,6 +55,7 @@ routines! { fn eval_string( routines: &Routines, world: Tracked, + sink: TrackedMut, string: &str, span: Span, mode: EvalMode, diff --git a/tests/suite/foundations/eval.typ b/tests/suite/foundations/eval.typ index f85146b23..85f43911c 100644 --- a/tests/suite/foundations/eval.typ +++ b/tests/suite/foundations/eval.typ @@ -52,3 +52,9 @@ _Tiger!_ #eval(mode: "math", "f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)") $f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)$ + +--- issue-6067-eval-warnings --- +// Test that eval shows warnings from the executed code. +// Warning: 7-11 no text within stars +// Hint: 7-11 using multiple consecutive stars (e.g. **) has no additional effect +#eval("**", mode: "markup") From 43c3d5d3afc945639a576535e48806112c161743 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:47:02 +0300 Subject: [PATCH 23/31] Improved ratio and relative length docs (#5750) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> Co-authored-by: Laurenz --- crates/typst-library/src/layout/ratio.rs | 32 ++++++++++++--- crates/typst-library/src/layout/rel.rs | 51 +++++++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/crates/typst-library/src/layout/ratio.rs b/crates/typst-library/src/layout/ratio.rs index 1c0dcd298..cf826c2b5 100644 --- a/crates/typst-library/src/layout/ratio.rs +++ b/crates/typst-library/src/layout/ratio.rs @@ -8,15 +8,35 @@ use crate::foundations::{repr, ty, Repr}; /// A ratio of a whole. /// -/// Written as a number, followed by a percent sign. +/// A ratio is written as a number, followed by a percent sign. Ratios most +/// often appear as part of a [relative length]($relative), to specify the size +/// of some layout element relative to the page or some container. /// -/// # Example /// ```example -/// #set align(center) -/// #scale(x: 150%)[ -/// Scaled apart. -/// ] +/// #rect(width: 25%) /// ``` +/// +/// However, they can also describe any other property that is relative to some +/// base, e.g. an amount of [horizontal scaling]($scale.x) or the +/// [height of parentheses]($math.lr.size) relative to the height of the content +/// they enclose. +/// +/// # Scripting +/// Within your own code, you can use ratios as you like. You can multiply them +/// with various other types as shown below: +/// +/// | Multiply by | Example | Result | +/// |-----------------|-------------------------|-----------------| +/// | [`ratio`] | `{27% * 10%}` | `{2.7%}` | +/// | [`length`] | `{27% * 100pt}` | `{27pt}` | +/// | [`relative`] | `{27% * (10% + 100pt)}` | `{2.7% + 27pt}` | +/// | [`angle`] | `{27% * 100deg}` | `{27deg}` | +/// | [`int`] | `{27% * 2}` | `{54%}` | +/// | [`float`] | `{27% * 0.37037}` | `{10%}` | +/// | [`fraction`] | `{27% * 3fr}` | `{0.81fr}` | +/// +/// When ratios are displayed in the document, they are rounded to two +/// significant digits for readability. #[ty(cast)] #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Ratio(Scalar); diff --git a/crates/typst-library/src/layout/rel.rs b/crates/typst-library/src/layout/rel.rs index 76d736785..7fe5d9c05 100644 --- a/crates/typst-library/src/layout/rel.rs +++ b/crates/typst-library/src/layout/rel.rs @@ -14,17 +14,58 @@ use crate::layout::{Abs, Em, Length, Ratio}; /// addition and subtraction of a length and a ratio. Wherever a relative length /// is expected, you can also use a bare length or ratio. /// -/// # Example -/// ```example -/// #rect(width: 100% - 50pt) +/// # Relative to the page +/// A common use case is setting the width or height of a layout element (e.g., +/// [block], [rect], etc.) as a certain percentage of the width of the page. +/// Here, the rectangle's width is set to `{25%}`, so it takes up one fourth of +/// the page's _inner_ width (the width minus margins). /// -/// #(100% - 50pt).length \ -/// #(100% - 50pt).ratio +/// ```example +/// #rect(width: 25%) /// ``` /// +/// Bare lengths or ratios are always valid where relative lengths are expected, +/// but the two can also be freely mixed: +/// ```example +/// #rect(width: 25% + 1cm) +/// ``` +/// +/// If you're trying to size an element so that it takes up the page's _full_ +/// width, you have a few options (this highly depends on your exact use case): +/// +/// 1. Set page margins to `{0pt}` (`[#set page(margin: 0pt)]`) +/// 2. Multiply the ratio by the known full page width (`{21cm * 69%}`) +/// 3. Use padding which will negate the margins (`[#pad(x: -2.5cm, ...)]`) +/// 4. Use the page [background](page.background) or +/// [foreground](page.foreground) field as those don't take margins into +/// account (note that it will render the content outside of the document +/// flow, see [place] to control the content position) +/// +/// # Relative to a container +/// When a layout element (e.g. a [rect]) is nested in another layout container +/// (e.g. a [block]) instead of being a direct descendant of the page, relative +/// widths become relative to the container: +/// +/// ```example +/// #block( +/// width: 100pt, +/// fill: aqua, +/// rect(width: 50%), +/// ) +/// ``` +/// +/// # Scripting +/// You can multiply relative lengths by [ratios]($ratio), [integers]($int), and +/// [floats]($float). +/// /// A relative length has the following fields: /// - `length`: Its length component. /// - `ratio`: Its ratio component. +/// +/// ```example +/// #(100% - 50pt).length \ +/// #(100% - 50pt).ratio +/// ``` #[ty(cast, name = "relative", title = "Relative Length")] #[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] pub struct Rel { From 9829bd8326fc67ebf78593bf4e860390c5ae8804 Mon Sep 17 00:00:00 2001 From: alluring-mushroom <86041465+alluring-mushroom@users.noreply.github.com> Date: Tue, 8 Apr 2025 05:56:20 +1000 Subject: [PATCH 24/31] Document exceptions and alternatives to using `type` (#6027) Co-authored-by: Zedd Serjeant Co-authored-by: Laurenz --- crates/typst-library/src/foundations/ty.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/ty.rs b/crates/typst-library/src/foundations/ty.rs index 40f7003c3..9d7690283 100644 --- a/crates/typst-library/src/foundations/ty.rs +++ b/crates/typst-library/src/foundations/ty.rs @@ -39,11 +39,25 @@ use crate::foundations::{ /// #type(image("glacier.jpg")). /// ``` /// -/// The type of `10` is `int`. Now, what is the type of `int` or even `type`? +/// The type of `{10}` is `int`. Now, what is the type of `int` or even `type`? /// ```example /// #type(int) \ /// #type(type) /// ``` +/// +/// Unlike other types like `int`, [none] and [auto] do not have a name +/// representing them. To test if a value is one of these, compare your value to +/// them directly, e.g: +/// ```example +/// #let val = none +/// #if val == none [ +/// Yep, it's none. +/// ] +/// ``` +/// +/// Note that `type` will return [`content`] for all document elements. To +/// programmatically determine which kind of content you are dealing with, see +/// [`content.func`]. #[ty(scope, cast)] #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct Type(Static); From 94a497a01ffd60743b0a2ae67367be168bbde076 Mon Sep 17 00:00:00 2001 From: Approximately Equal Date: Mon, 7 Apr 2025 13:18:52 -0700 Subject: [PATCH 25/31] Add HTML meta tags for document authors and keywords (#6134) --- crates/typst-html/src/lib.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index aa769976e..7d78a5da4 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -263,13 +263,13 @@ fn handle( /// Wrap the nodes in `` and `` if they are not yet rooted, /// supplying a suitable ``. fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let head = head_element(info); let body = match classify_output(output)? { OutputKind::Html(element) => return Ok(element), OutputKind::Body(body) => body, OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), }; - Ok(HtmlElement::new(tag::html) - .with_children(vec![head_element(info).into(), body.into()])) + Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) } /// Generate a `` element. @@ -302,6 +302,24 @@ fn head_element(info: &DocumentInfo) -> HtmlElement { ); } + if !info.author.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "authors") + .with_attr(attr::content, info.author.join(", ")) + .into(), + ) + } + + if !info.keywords.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "keywords") + .with_attr(attr::content, info.keywords.join(", ")) + .into(), + ) + } + HtmlElement::new(tag::head).with_children(children) } From c21c1c391b48f843c8671993a28eaf1fe0d40b89 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:27:42 +0200 Subject: [PATCH 26/31] Use `measure` `width` argument in `layout` doc (#6160) --- crates/typst-library/src/layout/layout.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/layout/layout.rs b/crates/typst-library/src/layout/layout.rs index cde3187d3..88252e5e3 100644 --- a/crates/typst-library/src/layout/layout.rs +++ b/crates/typst-library/src/layout/layout.rs @@ -22,7 +22,8 @@ use crate::layout::{BlockElem, Size}; /// #let text = lorem(30) /// #layout(size => [ /// #let (height,) = measure( -/// block(width: size.width, text), +/// width: size.width, +/// text, /// ) /// This text is #height high with /// the current page width: \ From 7e072e24930d8a7524f700b62cabd97ceb4f45e6 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 17 Apr 2025 14:10:27 +0000 Subject: [PATCH 27/31] Add test for flattened accents in math (#6188) --- tests/ref/math-accent-flattened.png | Bin 0 -> 464 bytes tests/suite/math/accent.typ | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 tests/ref/math-accent-flattened.png diff --git a/tests/ref/math-accent-flattened.png b/tests/ref/math-accent-flattened.png new file mode 100644 index 0000000000000000000000000000000000000000..f7764cb74144af9ca15f4a817511bfc5b2b291cd GIT binary patch literal 464 zcmV;>0WbcEP)8mP!bM$Vnv$5`(6eC>tD#OcbrCA+=02DzbvC49pjT#BwPi zkRnWQ(CWaeG`cvo!I#UsF7Ny_-~N=#^Zf@t+wYfWjmit*5Dwvg3BM|zlhfGEmr-)- z>S6x$VnFaWSbd%xlGDg&cLprZlKKvG|04b(82It=_t4%3q^fHka%@+mXI-!RCDZ&Y z<9AsCp9>C8xwpO}7V<^H?69mznqk2gZpM3k1G~eZ-|9wf<~LH0+Jm zgG4b(<}g)=s{TOW2iHAnG8WqlJVDrVrLls}Z>pJX&zO&dL3k2@0n%Q?F zauVA|&|TNRXQDaAjzK4yfaaE{cqvqNdXg>{2)uBny{GNkm5ev1u41XacjSB=PZ?MN z2Ai@5TJg5JHQjpa!+%_^m6_VFYciSfnn-vOAsoUX4EzOh@I@3%PB~xz0000 Date: Fri, 18 Apr 2025 17:27:07 +0300 Subject: [PATCH 28/31] Fix frac syntax section typo (#6193) --- crates/typst-library/src/math/frac.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs index f5c4514d6..dd5986b5f 100644 --- a/crates/typst-library/src/math/frac.rs +++ b/crates/typst-library/src/math/frac.rs @@ -15,7 +15,7 @@ use crate::math::Mathy; /// # Syntax /// This function also has dedicated syntax: Use a slash to turn neighbouring /// expressions into a fraction. Multiple atoms can be grouped into a single -/// expression using round grouping parenthesis. Such parentheses are removed +/// expression using round grouping parentheses. Such parentheses are removed /// from the output, but you can nest multiple to force them. #[elem(title = "Fraction", Mathy)] pub struct FracElem { From 14241ec1aae43ce3bff96411f62af76a01c7f709 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 1 May 2025 17:43:07 +0200 Subject: [PATCH 29/31] Use the right field name for `figure.caption.position` (#6226) --- crates/typst-library/src/model/figure.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index 78a79a8e2..5a137edbd 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -457,7 +457,7 @@ impl Outlinable for Packed { /// customize the appearance of captions for all figures or figures of a /// specific kind. /// -/// In addition to its `pos` and `body`, the `caption` also provides the +/// In addition to its `position` and `body`, the `caption` also provides the /// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These /// parts can be used in [`where`]($function.where) selectors and show rules to /// build a completely custom caption. From b322da930fe35ee3d19896de6ab653e2f321e301 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 6 May 2025 10:26:55 +0200 Subject: [PATCH 30/31] Respect RTL cell layouting order in grid layout (#6232) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> --- crates/typst-layout/src/grid/layouter.rs | 60 +++----- crates/typst-layout/src/grid/rowspans.rs | 16 +- tests/ref/grid-rtl-counter.png | Bin 0 -> 272 bytes tests/ref/grid-rtl-rowspan-counter-equal.png | Bin 0 -> 272 bytes .../ref/grid-rtl-rowspan-counter-mixed-1.png | Bin 0 -> 360 bytes .../ref/grid-rtl-rowspan-counter-mixed-2.png | Bin 0 -> 361 bytes .../grid-rtl-rowspan-counter-unequal-1.png | Bin 0 -> 361 bytes .../grid-rtl-rowspan-counter-unequal-2.png | Bin 0 -> 360 bytes tests/suite/layout/grid/rtl.typ | 140 ++++++++++++++++++ 9 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 tests/ref/grid-rtl-counter.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-equal.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-mixed-1.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-mixed-2.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-unequal-1.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-unequal-2.png diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index dc9e2238d..99b85eddb 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -11,7 +11,7 @@ use typst_library::layout::{ use typst_library::text::TextElem; use typst_library::visualize::Geometry; use typst_syntax::Span; -use typst_utils::{MaybeReverseIter, Numeric}; +use typst_utils::Numeric; use super::{ generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row, @@ -574,7 +574,7 @@ impl<'a> GridLayouter<'a> { // Reverse with RTL so that later columns start first. let mut dx = Abs::zero(); - for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &col) in self.rcols.iter().enumerate() { let mut dy = Abs::zero(); for row in rows { // We want to only draw the fill starting at the parent @@ -643,18 +643,13 @@ impl<'a> GridLayouter<'a> { .sum() }; let width = self.cell_spanned_width(cell, x); - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, cell fills would start at the - // rightmost visual position of a cell and extend - // over to unrelated columns to the right in RTL. - // We avoid this by ensuring the fill starts at the - // very left of the cell, even with colspan > 1. - let offset = - if self.is_rtl { -width + col } else { Abs::zero() }; - let pos = Point::new(dx + offset, dy); + let mut pos = Point::new(dx, dy); + if self.is_rtl { + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (dx + width); + } let size = Size::new(width, height); let rect = Geometry::Rect(size).filled(fill); fills.push((pos, FrameItem::Shape(rect, self.span))); @@ -1236,10 +1231,9 @@ impl<'a> GridLayouter<'a> { } let mut output = Frame::soft(Size::new(self.width, height)); - let mut pos = Point::zero(); + let mut offset = Point::zero(); - // Reverse the column order when using RTL. - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1257,25 +1251,17 @@ impl<'a> GridLayouter<'a> { let frame = layout_cell(cell, engine, disambiguator, self.styles, pod)? .into_frame(); - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, the cell's contents would be laid out - // starting at its rightmost visual position and extend - // over to unrelated cells to its right in RTL. - // We avoid this by ensuring the rendered cell starts at - // the very left of the cell, even with colspan > 1. - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + pos.x = self.width - (pos.x + width); } output.push_frame(pos, frame); } } - pos.x += rcol; + offset.x += rcol; } Ok(output) @@ -1302,8 +1288,8 @@ impl<'a> GridLayouter<'a> { pod.backlog = &heights[1..]; // Layout the row. - let mut pos = Point::zero(); - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + let mut offset = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1314,17 +1300,19 @@ impl<'a> GridLayouter<'a> { let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; for (output, frame) in outputs.iter_mut().zip(fragment) { - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (offset.x + width); } output.push_frame(pos, frame); } } } - pos.x += rcol; + offset.x += rcol; } Ok(Fragment::frames(outputs)) diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 21992ed02..5ab0417d8 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -3,7 +3,6 @@ use typst_library::engine::Engine; use typst_library::foundations::Resolve; use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; -use typst_utils::MaybeReverseIter; use super::layouter::{in_last_with_offset, points, Row, RowPiece}; use super::{layout_cell, Cell, GridLayouter}; @@ -23,6 +22,10 @@ pub struct Rowspan { /// specified for the parent cell's `breakable` field. pub is_effectively_unbreakable: bool, /// The horizontal offset of this rowspan in all regions. + /// + /// This is the offset from the text direction start, meaning that, on RTL + /// grids, this is the offset from the right of the grid, whereas, on LTR + /// grids, it is the offset from the left. pub dx: Abs, /// The vertical offset of this rowspan in the first region. pub dy: Abs, @@ -118,10 +121,11 @@ impl GridLayouter<'_> { // Nothing to layout. return Ok(()); }; - let first_column = self.rcols[x]; let cell = self.grid.cell(x, y).unwrap(); let width = self.cell_spanned_width(cell, x); - let dx = if self.is_rtl { dx - width + first_column } else { dx }; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + let dx = if self.is_rtl { self.width - (dx + width) } else { dx }; // Prepare regions. let size = Size::new(width, *first_height); @@ -185,10 +189,8 @@ impl GridLayouter<'_> { /// Checks if a row contains the beginning of one or more rowspan cells. /// If so, adds them to the rowspans vector. pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) { - // We will compute the horizontal offset of each rowspan in advance. - // For that reason, we must reverse the column order when using RTL. - let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl)); - for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) { + let offsets = points(self.rcols.iter().copied()); + for (x, dx) in (0..self.rcols.len()).zip(offsets) { let Some(cell) = self.grid.cell(x, y) else { continue; }; diff --git a/tests/ref/grid-rtl-counter.png b/tests/ref/grid-rtl-counter.png new file mode 100644 index 0000000000000000000000000000000000000000..fb0df44ad40da59bfc8ee7d98b1445de8c70d3a3 GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=Y0VEjK$QP*tsq3CDjv*Ddl7HAcG$dYm6xi*q zE4Q@*#5lR}$N9$H%qfMZf+Rj4Ul(fcY4Y(nv{N3;QLEU8< zD>nBvMb>BE7Z5$5uXcuyIY=aW&y(;2^ACqNtodcEuP&Tic=DpR>E*9$tG7JM57(nv{N3;QLEU8< zD>nBvMb>BE7Z5$5uXcuyIY=aW&y(;2^ACqNtodcEuP&Tic=DpR>E*9$tG7JM57}9D*9cp;sUck_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&ie+z?dM&b1i$a}Y@h83(4ztZ3oNj}0t@U0HjmUE!Qs3;_5Ce_?o#hv!=wuK zihQl6>8f)7fd@FckmckGXtEc_L7yA(I=im`bB7+_HGM<*t? z%Ra)5fn1g$4O)w}(6^5;`niq?aHm*uAOZ_4u)qTUGx!I#s*xWevRUx}0000{9D*9cp+_JMk_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&U*)ew4R6jg8F`@_x9R8K7MIFfdv*=V1Wfz1KS6Bw_*L(6&%dA)7;&#x&Rk@ z_i(dBu3e#z<)ml$eJxsL`x#(n-yIyDOLKGq)Y*x8hL5i+;~l~)_#eMvn!z{E@Ka)G z1n30L5h?-DioM_&o+zCM*lYkqw%oz1&0+gc1Ex>vDbMg(yb#gzuYfigH*m4SLgI?@ zB`_Nv%gb{KEU>`;5q1{Wrja8>3}Eg9cC=%BY01DJ(1i=b`6LA)Ji; zbg-xpuJvWo3>eUuuLM37!kDKfHo%SM$bkqfu)qQf{O92>(vXoKDr-mq00000NkvXX Hu0mjfwc?u% literal 0 HcmV?d00001 diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-1.png b/tests/ref/grid-rtl-rowspan-counter-unequal-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c091f3a806bb3bbd5cc37d2e5372c59005093466 GIT binary patch literal 361 zcmV-v0ha!WP){9D*9cp+_JMk_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&U*)ew4R6jg8F`@_x9R8K7MIFfdv*=V1Wfz1KS6Bw_*L(6&%dA)7;&#x&Rk@ z_i(dBu3e#z<)ml$eJxsL`x#(n-yIyDOLKGq)Y*x8hL5i+;~l~)_#eMvn!z{E@Ka)G z1n30L5h?-DioM_&o+zCM*lYkqw%oz1&0+gc1Ex>vDbMg(yb#gzuYfigH*m4SLgI?@ zB`_Nv%gb{KEU>`;5q1{Wrja8>3}Eg9cC=%BY01DJ(1i=b`6LA)Ji; zbg-xpuJvWo3>eUuuLM37!kDKfHo%SM$bkqfu)qQf{O92>(vXoKDr-mq00000NkvXX Hu0mjfwc?u% literal 0 HcmV?d00001 diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-2.png b/tests/ref/grid-rtl-rowspan-counter-unequal-2.png new file mode 100644 index 0000000000000000000000000000000000000000..fffccc5664edfcd379a237268f14dd21e18fa39a GIT binary patch literal 360 zcmV-u0hj)XP)}9D*9cp;sUck_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&ie+z?dM&b1i$a}Y@h83(4ztZ3oNj}0t@U0HjmUE!Qs3;_5Ce_?o#hv!=wuK zihQl6>8f)7fd@FckmckGXtEc_L7yA(I=im`bB7+_HGM<*t? z%Ra)5fn1g$4O)w}(6^5;`niq?aHm*uAOZ_4u)qTUGx!I#s*xWevRUx}0000 ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() ) + +--- grid-rtl-counter --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + [ + a: // should produce 1 + #test.step() + #context test.get().first() + ], + [ + b: // should produce 2 + #test.step() + #context test.get().first() + ], +) + +--- grid-rtl-rowspan-counter-equal --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + a: // should produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 2, [ + b: // should produce 2 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-unequal-1 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 5, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 2, [ + a: // will produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 3, [ + c: // will produce 3 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-unequal-2 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + a: // will produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 5, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 3, [ + c: // will produce 3 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-mixed-1 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + [ + a: // will produce 1 + #test.step() + #context test.get().first() + ], + grid.cell(rowspan: 2, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + [ + c: // will produce 3 + #test.step() + #context test.get().first() + ], +) + +--- grid-rtl-rowspan-counter-mixed-2 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + [ + a: // will produce 1 + #test.step() + #context test.get().first() + ], + [ + c: // will produce 3 + #test.step() + #context test.get().first() + ] +) From 9b09146a6b5e936966ed7ee73bce9dd2df3810ae Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 6 May 2025 16:03:48 +0200 Subject: [PATCH 31/31] Use list spacing for attach spacing in tight lists (#6242) --- crates/typst-library/src/model/enum.rs | 9 +++++---- crates/typst-library/src/model/list.rs | 9 +++++---- crates/typst-library/src/model/terms.rs | 8 +++++--- .../ref/issue-6242-tight-list-attach-spacing.png | Bin 0 -> 410 bytes tests/suite/model/list.typ | 8 ++++++++ 5 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 tests/ref/issue-6242-tight-list-attach-spacing.png diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index 2d95996ab..f1f93702b 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -259,10 +259,11 @@ impl Show for Packed { .spanned(self.span()); if tight { - let leading = ParElem::leading_in(styles); - let spacing = - VElem::new(leading.into()).with_weak(true).with_attach(true).pack(); - realized = spacing + realized; + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()).with_weak(true).with_attach(true).pack(); + realized = v + realized; } Ok(realized) diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index d93ec9172..3c3afd338 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -166,10 +166,11 @@ impl Show for Packed { .spanned(self.span()); if tight { - let leading = ParElem::leading_in(styles); - let spacing = - VElem::new(leading.into()).with_weak(true).with_attach(true).pack(); - realized = spacing + realized; + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()).with_weak(true).with_attach(true).pack(); + realized = v + realized; } Ok(realized) diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index e197ff318..3df74cd9e 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -189,13 +189,15 @@ impl Show for Packed { .styled(TermsElem::set_within(true)); if tight { - let leading = ParElem::leading_in(styles); - let spacing = VElem::new(leading.into()) + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()) .with_weak(true) .with_attach(true) .pack() .spanned(span); - realized = spacing + realized; + realized = v + realized; } Ok(realized) diff --git a/tests/ref/issue-6242-tight-list-attach-spacing.png b/tests/ref/issue-6242-tight-list-attach-spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..48920008b1350f8b604d4e06842d25b282ae1afd GIT binary patch literal 410 zcmV;L0cHM)P)0004DNkl_mzthIoO<(BZ&hpc2JIEVmAj-D~C-E!p%iy#TpKiDY3^`zc26O_^cr~*i$nC=fnOZLAW@u z4gf5kZj1@SXHC5TQ1Mz;cS#N=Rseud8kRTZGrsj6P+jrmwLlbBSYd_#0i2By8sZG5zg4v90VZUCk*?O0PyXO zLgqz2W6G<6ohvO6g%ws<;l_k>sj><9b^1Iucx(V3zlXQ5Ap8jc^$y{Tv#pZ=l-h;i zo0bs(aAtuYIhc~YlkUsne#XQ*RpXI&Z7zisR=8o|U!MOHPD9hDwg3PC07*qoM6N<$ Eg2vyu4*&oF literal 0 HcmV?d00001 diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ index 9bed930bb..796a7b069 100644 --- a/tests/suite/model/list.typ +++ b/tests/suite/model/list.typ @@ -304,3 +304,11 @@ World - C - = D E + +--- issue-6242-tight-list-attach-spacing --- +// Nested tight lists should be uniformly spaced when list spacing is set. +#set list(spacing: 1.2em) +- A + - B + - C +- C