Compare commits

...

81 Commits

Author SHA1 Message Date
Tobias Schmitz
93b42bd2fc
Merge de72040ce6e55aa6949f160c6d711cf6f3057c70 into e9f1b5825a9d37ca0c173a7b2830ba36a27ca9e0 2025-07-24 21:30:11 +09:00
Tobias Schmitz
de72040ce6
Merge branch 'main' into pdf-accessibility 2025-07-22 23:31:41 +02:00
Tobias Schmitz
9649def108
feat: add alt parameter to math.equation 2025-07-18 16:38:54 +02:00
Tobias Schmitz
d2105dcc35
feat: report spans for missing alt text and unknown/duplicate tag ids 2025-07-18 16:38:54 +02:00
Tobias Schmitz
99815f449c
feat: best effort link alt text generation 2025-07-18 12:01:54 +02:00
Tobias Schmitz
79423f3033
refactor: revert some changes to main 2025-07-17 17:33:27 +02:00
Tobias Schmitz
f8f900d40b
feat: update krilla
bounding boxes for links are now automatically generated by krilla
2025-07-17 16:52:37 +02:00
Tobias Schmitz
0bd0dc6d92
feat: generate tags for bibliographies 2025-07-17 16:12:10 +02:00
Tobias Schmitz
8d2c8712d5
feat: wrap equations in Formula tags 2025-07-16 16:34:42 +02:00
Tobias Schmitz
66ca4dc9a0
feat: generate tags for quotes 2025-07-16 14:54:58 +02:00
Tobias Schmitz
bc09df0c8b
feat: insert footnotes after the reference in the reading order 2025-07-16 14:52:20 +02:00
Tobias Schmitz
bf75ab858d
feat: better alt text for footnote links 2025-07-16 14:04:22 +02:00
Tobias Schmitz
153c5d3a4a
refactor: update krilla 2025-07-16 13:58:04 +02:00
Tobias Schmitz
39a2c31169
Merge branch 'main' into pdf-accessibility 2025-07-16 12:59:06 +02:00
Tobias Schmitz
cd5d91a82d
fix: ensure link annotation object references are direct children of link tags 2025-07-15 17:23:11 +02:00
Tobias Schmitz
728d37efa0
feat: generate tags for footnotes 2025-07-15 16:54:15 +02:00
Tobias Schmitz
451b0815ff
feat: mark numbering prefix of heading and outline as Lbl 2025-07-14 17:11:02 +02:00
Tobias Schmitz
0df9da7ce6
feat: generate tags for terms 2025-07-14 16:37:16 +02:00
Tobias Schmitz
4b57373653
feat: derive Debug for StackEntry 2025-07-14 13:18:43 +02:00
Tobias Schmitz
e43b8bbb7f
fix: out of bounds access when tagging table cells 2025-07-14 12:40:18 +02:00
Tobias Schmitz
9bbfe4c14a
fix: make figure captions sibling elements
if the caption is contained within the figure screen readers might ignore it
2025-07-14 10:46:00 +02:00
Tobias Schmitz
e4021390a3
feat: don't wrap table cell content in paragraph 2025-07-14 10:46:00 +02:00
Tobias Schmitz
2621c6416e
feat!: revert making some elements Locatable 2025-07-13 18:06:03 +02:00
Tobias Schmitz
e5e5fba418
fix: revert making math elements Locatable 2025-07-13 17:53:55 +02:00
Tobias Schmitz
3c46056599
fix: public outline entry.inner() function 2025-07-13 17:46:55 +02:00
Tobias Schmitz
b5c6f7132b
feat!: remove unfinished manual tagging code for now 2025-07-13 17:38:19 +02:00
Tobias Schmitz
484f633e27
chore: remove left over file from merge 2025-07-13 17:30:37 +02:00
Tobias Schmitz
eb9a3359d5
feat: generate tags for lists and enums 2025-07-13 17:27:02 +02:00
Tobias Schmitz
a495724813
feat: mark all shapes as artifacts 2025-07-12 20:02:28 +02:00
Tobias Schmitz
e3c0855a2b
fix: update krilla 2025-07-12 14:15:13 +02:00
Tobias Schmitz
e0074d6e39
refactor: make TableCell::kind #[parse] instead of #[synthesized] 2025-07-11 14:45:45 +02:00
Tobias Schmitz
e8af101a79
Merge branch 'main' into pdf-accessibility 2025-07-11 14:13:34 +02:00
Tobias Schmitz
0a0830ff93
fix: update krilla 2025-07-10 15:41:31 +02:00
Tobias Schmitz
df10cb8570
feat: default to the url if no alt text is specified for a link 2025-07-09 10:18:48 +02:00
Tobias Schmitz
08719237c2
feat!: for now don't generate paragraphs 2025-07-09 10:18:48 +02:00
Tobias Schmitz
8998676acb
feat: group artifacts
span one artifact tag across all content inside an artifact
2025-07-09 10:18:39 +02:00
Tobias Schmitz
0c09c7d666
Merge branch 'main' into pdf-accessibility 2025-07-08 14:57:51 +02:00
Tobias Schmitz
edd213074f
refactor: remove general api to set cell kind and add pdf.(header|data)-cell 2025-07-08 14:14:37 +02:00
Tobias Schmitz
070a0faf5c
fixup! test: table header id generation 2025-07-08 14:14:21 +02:00
Tobias Schmitz
2445bb4361
fix: table header hierarchy resolution 2025-07-08 11:28:35 +02:00
Tobias Schmitz
7d5b9a716f
feat: wrap table cell content in a paragraph 2025-07-07 12:30:56 +02:00
Tobias Schmitz
b0d3c2dca4
test: table header id generation 2025-07-07 12:28:53 +02:00
Tobias Schmitz
58c6729df4
feat: generate human readable table cell IDs
in almost all real-world cases these IDs require less memory than the binary IDs
used before, and they are also require less storage in PDF files, since binary
data is encoded in hex escape sequences, taking up 4 bytes per byte of data.
2025-07-07 10:52:20 +02:00
Tobias Schmitz
157e0fa142
fix: generate cell id with correct indices 2025-07-04 15:56:39 +02:00
Tobias Schmitz
4dceb7f5ef
refactor: update krilla 2025-07-04 10:37:46 +02:00
Tobias Schmitz
3d4d548934
feat: [WIP] generate alt text for ref elements 2025-07-03 18:43:30 +02:00
Tobias Schmitz
254aadccfc
docs: fix comment 2025-07-03 18:43:20 +02:00
Tobias Schmitz
8e10356234
refactor: use krilla as git dependency 2025-07-03 18:43:20 +02:00
Tobias Schmitz
7892a8c726
chore: update krilla 2025-07-03 18:43:20 +02:00
Tobias Schmitz
f324accff9
feat: generate paragraphs 2025-07-03 18:43:20 +02:00
Tobias Schmitz
0bc39338a1
fix: handle some edge cases instead of panicking 2025-07-03 18:43:20 +02:00
Tobias Schmitz
377dc87325
refactor: split up pdf tagging code into multiple modules 2025-07-03 18:43:20 +02:00
Tobias Schmitz
50cd81ee1f
feat: generate headers attribute table cells
- fix marking repeated headers/footers as artifacts
- fix table row grouping with empty cells
2025-07-03 18:43:17 +02:00
Tobias Schmitz
746926c7da
fix: ignore repeated table headers/footers in tag tree 2025-07-03 18:43:15 +02:00
Tobias Schmitz
773efb5572
fix: bug due to table cell start tags in grid layout code 2025-07-03 18:43:13 +02:00
Tobias Schmitz
3404fecd36
feat: tag table headers and footers 2025-07-03 18:43:10 +02:00
Tobias Schmitz
bfcf2bd4cc
feat: support headings with level >= 7 2025-07-03 18:43:08 +02:00
Tobias Schmitz
605681d435
refactor: move link tagging code 2025-07-03 18:43:04 +02:00
Tobias Schmitz
6ebe85d678
fix: don't include outline title in TOC hierarchy 2025-07-03 18:43:02 +02:00
Tobias Schmitz
76d09b5673
fix: only use link annotation quadpoints when exporting a PDF/UA-1 document 2025-07-03 18:43:02 +02:00
Tobias Schmitz
d6307831dd
feat: hierarchical outline tags 2025-07-03 18:42:59 +02:00
Tobias Schmitz
09b2cd6de5
docs: fixup some comments 2025-07-03 18:42:57 +02:00
Tobias Schmitz
6717a18414
feat: mark RepeatElem as artifact 2025-07-03 18:42:54 +02:00
Tobias Schmitz
612aa8fc53
fix: mark table gutter and fill as artifacts 2025-07-03 18:42:52 +02:00
Tobias Schmitz
5bd9accb9c
feat: always write alt text in marked content sequence for images 2025-07-03 18:42:52 +02:00
Tobias Schmitz
0d35ae28ad
feat: add cli args for PDF/UA-1 standard and to disable tagging 2025-07-03 18:42:46 +02:00
Tobias Schmitz
4894a227d2
refactor: revert some changes to FrameItem::Link 2025-07-03 18:42:46 +02:00
Tobias Schmitz
2d6e3b6151
refactor: derive(Cast) for ArtifactKind 2025-07-03 18:42:44 +02:00
Tobias Schmitz
e6341c0fe4
fix: avoid empty marked-content sequences 2025-07-03 18:42:41 +02:00
Tobias Schmitz
8231439b11
feat: generate tags for tables 2025-07-03 18:42:38 +02:00
Tobias Schmitz
8075f551e2
feat: use local krilla version 2025-07-03 18:42:35 +02:00
Tobias Schmitz
ac6b9d6008
feat: pdf.tag function to manually create pdf tags 2025-07-03 18:42:31 +02:00
Tobias Schmitz
00c3b62f1d
feat: write tags for more elements 2025-07-03 18:42:23 +02:00
Tobias Schmitz
6c686bd460
feat: write tags for links and use quadpoints in link annotations 2025-07-03 18:42:10 +02:00
Tobias Schmitz
9e2235dbd8
feat: pdf.artifact element 2025-07-03 18:42:10 +02:00
Tobias Schmitz
1980430578
feat: mark artifacts 2025-07-03 18:41:48 +02:00
Tobias Schmitz
cc70a785dd
feat: [WIP] allow specifying alt text for links
skip-checks:true

# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# Date:      Wed May 28 17:47:35 2025 +0200
#
# On branch pdf-accessibility
# Your branch and 'origin/pdf-accessibility' have diverged,
# and have 11 and 5 different commits each, respectively.
#
# Changes to be committed:
#	modified:   crates/typst-ide/src/jump.rs
#	modified:   crates/typst-layout/src/flow/distribute.rs
#	modified:   crates/typst-layout/src/modifiers.rs
#	modified:   crates/typst-library/src/foundations/content.rs
#	modified:   crates/typst-library/src/layout/frame.rs
#	modified:   crates/typst-library/src/model/bibliography.rs
#	modified:   crates/typst-library/src/model/footnote.rs
#	modified:   crates/typst-library/src/model/link.rs
#	modified:   crates/typst-library/src/model/outline.rs
#	modified:   crates/typst-library/src/model/reference.rs
#	modified:   crates/typst-pdf/src/convert.rs
#	modified:   crates/typst-pdf/src/link.rs
#	modified:   crates/typst-render/src/lib.rs
#	modified:   crates/typst-svg/src/lib.rs
#	modified:   tests/src/run.rs
#
2025-07-03 15:58:07 +02:00
Tobias Schmitz
e8ea837514
feat: [WIP] include links in tag tree
skip-checks:true
2025-07-03 15:58:07 +02:00
Tobias Schmitz
c6b3b371b0
feat: [WIP] write tags
skip-checks:true
2025-07-03 15:58:07 +02:00
Tobias Schmitz
ab7eea23f1
feat: [WIP] make more things locatable
skip-checks:true
2025-07-03 15:58:07 +02:00
Tobias Schmitz
c5dbd85a81
feat: [draft] generate accessibility tag tree for headings
skip-checks:true
2025-07-03 15:58:07 +02:00
86 changed files with 2209 additions and 257 deletions

29
Cargo.lock generated
View File

@ -592,6 +592,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@ -1424,7 +1430,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla" name = "krilla"
version = "0.4.0" version = "0.4.0"
source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd"
dependencies = [ dependencies = [
"base64", "base64",
"bumpalo", "bumpalo",
@ -1454,7 +1460,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla-svg" name = "krilla-svg"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd"
dependencies = [ dependencies = [
"flate2", "flate2",
"fontdb", "fontdb",
@ -2040,6 +2046,16 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.93" version = "1.0.93"
@ -3193,6 +3209,7 @@ dependencies = [
name = "typst-pdf" name = "typst-pdf"
version = "0.13.1" version = "0.13.1"
dependencies = [ dependencies = [
"az",
"bytemuck", "bytemuck",
"comemo", "comemo",
"ecow", "ecow",
@ -3200,7 +3217,9 @@ dependencies = [
"infer", "infer",
"krilla", "krilla",
"krilla-svg", "krilla-svg",
"pretty_assertions",
"serde", "serde",
"smallvec",
"typst-assets", "typst-assets",
"typst-library", "typst-library",
"typst-macros", "typst-macros",
@ -3867,6 +3886,12 @@ dependencies = [
"linked-hash-map", "linked-hash-map",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.7.5" version = "0.7.5"

View File

@ -74,8 +74,8 @@ image = { version = "0.25.5", default-features = false, features = ["png", "jpeg
indexmap = { version = "2", features = ["serde"] } indexmap = { version = "2", features = ["serde"] }
infer = { version = "0.19.0", default-features = false } infer = { version = "0.19.0", default-features = false }
kamadak-exif = "0.6" kamadak-exif = "0.6"
krilla = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } krilla = { git = "https://github.com/LaurenzV/krilla", branch = "main", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] }
krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00"} krilla-svg = { git = "https://github.com/LaurenzV/krilla", branch = "main" }
kurbo = "0.11" kurbo = "0.11"
libfuzzer-sys = "0.4" libfuzzer-sys = "0.4"
lipsum = "0.9" lipsum = "0.9"
@ -93,6 +93,7 @@ phf = { version = "0.11", features = ["macros"] }
pixglyph = "0.6" pixglyph = "0.6"
png = "0.17" png = "0.17"
portable-atomic = "1.6" portable-atomic = "1.6"
pretty_assertions = "1.4.1"
proc-macro2 = "1" proc-macro2 = "1"
pulldown-cmark = "0.9" pulldown-cmark = "0.9"
qcms = "0.3.0" qcms = "0.3.0"

View File

@ -258,6 +258,13 @@ pub struct CompileArgs {
#[arg(long = "pdf-standard", value_delimiter = ',')] #[arg(long = "pdf-standard", value_delimiter = ',')]
pub pdf_standard: Vec<PdfStandard>, pub pdf_standard: Vec<PdfStandard>,
/// By default, even when not producing a `PDF/UA-1` document, a tagged PDF
/// document is written to provide a baseline of accessibility. In some
/// circumstances (for example when trying to reduce the size of a document)
/// it can be desirable to disable tagged PDF.
#[arg(long = "disable-pdf-tags")]
pub disable_pdf_tags: bool,
/// The PPI (pixels per inch) to use for PNG export. /// The PPI (pixels per inch) to use for PNG export.
#[arg(long = "ppi", default_value_t = 144.0)] #[arg(long = "ppi", default_value_t = 144.0)]
pub ppi: f32, pub ppi: f32,
@ -518,6 +525,9 @@ pub enum PdfStandard {
/// PDF/A-4e. /// PDF/A-4e.
#[value(name = "a-4e")] #[value(name = "a-4e")]
A_4e, A_4e,
/// PDF/UA-1.
#[value(name = "ua-1")]
Ua_1,
} }
display_possible_values!(PdfStandard); display_possible_values!(PdfStandard);

View File

@ -65,6 +65,8 @@ pub struct CompileConfig {
pub open: Option<Option<String>>, pub open: Option<Option<String>>,
/// A list of standards the PDF should conform to. /// A list of standards the PDF should conform to.
pub pdf_standards: PdfStandards, pub pdf_standards: PdfStandards,
/// Whether to write PDF (accessibility) tags.
pub disable_pdf_tags: bool,
/// A path to write a Makefile rule describing the current compilation. /// A path to write a Makefile rule describing the current compilation.
pub make_deps: Option<PathBuf>, pub make_deps: Option<PathBuf>,
/// The PPI (pixels per inch) to use for PNG export. /// The PPI (pixels per inch) to use for PNG export.
@ -150,6 +152,7 @@ impl CompileConfig {
output_format, output_format,
pages, pages,
pdf_standards, pdf_standards,
disable_pdf_tags: args.disable_pdf_tags,
creation_timestamp: args.world.creation_timestamp, creation_timestamp: args.world.creation_timestamp,
make_deps: args.make_deps.clone(), make_deps: args.make_deps.clone(),
ppi: args.ppi, ppi: args.ppi,
@ -291,6 +294,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<
timestamp, timestamp,
page_ranges: config.pages.clone(), page_ranges: config.pages.clone(),
standards: config.pdf_standards.clone(), standards: config.pdf_standards.clone(),
disable_tags: config.disable_pdf_tags,
}; };
let buffer = typst_pdf::pdf(document, &options)?; let buffer = typst_pdf::pdf(document, &options)?;
config config
@ -775,6 +779,7 @@ impl From<PdfStandard> for typst_pdf::PdfStandard {
PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, PdfStandard::A_4 => typst_pdf::PdfStandard::A_4,
PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f,
PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e,
PdfStandard::Ua_1 => typst_pdf::PdfStandard::Ua_1,
} }
} }
} }

View File

@ -1279,14 +1279,23 @@ impl<'a> GridLayouter<'a> {
let frames = let frames =
layout_cell(cell, engine, disambiguator, self.styles, pod)?.into_frames(); layout_cell(cell, engine, disambiguator, self.styles, pod)?.into_frames();
// HACK: reconsider if this is the right decision
fn is_empty_frame(frame: &Frame) -> bool {
!frame.items().any(|(_, item)| match item {
FrameItem::Group(group) => is_empty_frame(&group.frame),
FrameItem::Tag(_) => false,
_ => true,
})
}
// Skip the first region if one cell in it is empty. Then, // Skip the first region if one cell in it is empty. Then,
// remeasure. // remeasure.
if let Some([first, rest @ ..]) = if let Some([first, rest @ ..]) =
frames.get(measurement_data.frames_in_previous_regions..) frames.get(measurement_data.frames_in_previous_regions..)
&& can_skip && can_skip
&& breakable && breakable
&& first.is_empty() && is_empty_frame(first)
&& rest.iter().any(|frame| !frame.is_empty()) && rest.iter().any(|frame| !is_empty_frame(frame))
{ {
return Ok(None); return Ok(None);
} }

View File

@ -7,6 +7,7 @@ use typst_library::introspection::Locator;
use typst_library::layout::grid::resolve::{Cell, CellGrid}; use typst_library::layout::grid::resolve::{Cell, CellGrid};
use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment}; use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment};
use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem}; use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem};
use typst_library::pdf::PdfMarkerTag;
use typst_library::text::TextElem; use typst_library::text::TextElem;
use crate::grid::GridLayouter; use crate::grid::GridLayouter;
@ -44,12 +45,16 @@ pub fn layout_list(
if !tight { if !tight {
body += ParbreakElem::shared(); body += ParbreakElem::shared();
} }
let body = body.set(ListElem::depth, Depth(1));
cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&())));
cells.push(Cell::new(marker.clone(), locator.next(&marker.span()))); cells.push(Cell::new(
PdfMarkerTag::ListItemLabel(marker.clone()),
locator.next(&marker.span()),
));
cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&())));
cells.push(Cell::new( cells.push(Cell::new(
body.set(ListElem::depth, Depth(1)), PdfMarkerTag::ListItemBody(body),
locator.next(&item.body.span()), locator.next(&item.body.span()),
)); ));
} }
@ -131,11 +136,13 @@ pub fn layout_enum(
body += ParbreakElem::shared(); body += ParbreakElem::shared();
} }
let body = body.set(EnumElem::parents, smallvec![number]);
cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&())));
cells.push(Cell::new(resolved, locator.next(&()))); cells.push(Cell::new(PdfMarkerTag::ListItemLabel(resolved), locator.next(&())));
cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&())));
cells.push(Cell::new( cells.push(Cell::new(
body.set(EnumElem::parents, smallvec![number]), PdfMarkerTag::ListItemBody(body),
locator.next(&item.body.span()), locator.next(&item.body.span()),
)); ));
number = number =

View File

@ -14,6 +14,7 @@ use typst_library::layout::{
VAlignment, VAlignment,
}; };
use typst_library::model::Numbering; use typst_library::model::Numbering;
use typst_library::pdf::ArtifactKind;
use typst_library::routines::{Pair, Routines}; use typst_library::routines::{Pair, Routines};
use typst_library::text::{LocalName, TextElem}; use typst_library::text::{LocalName, TextElem};
use typst_library::visualize::Paint; use typst_library::visualize::Paint;
@ -202,6 +203,11 @@ fn layout_page_run_impl(
// Layout marginals. // Layout marginals.
let mut layouted = Vec::with_capacity(fragment.len()); let mut layouted = Vec::with_capacity(fragment.len());
let header = header.as_ref().map(|h| h.clone().artifact(ArtifactKind::Header));
let footer = footer.as_ref().map(|f| f.clone().artifact(ArtifactKind::Footer));
let background = background.as_ref().map(|b| b.clone().artifact(ArtifactKind::Page));
for inner in fragment { for inner in fragment {
let header_size = Size::new(inner.width(), margin.top - header_ascent); let header_size = Size::new(inner.width(), margin.top - header_ascent);
let footer_size = Size::new(inner.width(), margin.bottom - footer_descent); let footer_size = Size::new(inner.width(), margin.bottom - footer_descent);
@ -212,9 +218,9 @@ fn layout_page_run_impl(
fill: fill.clone(), fill: fill.clone(),
numbering: numbering.clone(), numbering: numbering.clone(),
supplement: supplement.clone(), supplement: supplement.clone(),
header: layout_marginal(header, header_size, Alignment::BOTTOM)?, header: layout_marginal(&header, header_size, Alignment::BOTTOM)?,
footer: layout_marginal(footer, footer_size, Alignment::TOP)?, footer: layout_marginal(&footer, footer_size, Alignment::TOP)?,
background: layout_marginal(background, full_size, mid)?, background: layout_marginal(&background, full_size, mid)?,
foreground: layout_marginal(foreground, full_size, mid)?, foreground: layout_marginal(foreground, full_size, mid)?,
margin, margin,
binding, binding,

View File

@ -5,16 +5,15 @@ use ecow::{EcoVec, eco_format};
use smallvec::smallvec; use smallvec::smallvec;
use typst_library::diag::{At, SourceResult, bail}; use typst_library::diag::{At, SourceResult, bail};
use typst_library::foundations::{ use typst_library::foundations::{
Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, dict, Content, Context, LinkMarker, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, StyleChain, Target
StyleChain, Target, dict,
}; };
use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::introspection::{Counter, Locator, LocatorLink};
use typst_library::layout::{ use typst_library::layout::{
Abs, AlignElem, Alignment, Axes, BlockBody, BlockElem, ColumnsElem, Em, GridCell, Abs, AlignElem, Alignment, Axes, BlockBody, BlockElem, ColumnsElem, Em, GridCell,
GridChild, GridElem, GridItem, HAlignment, HElem, HideElem, InlineElem, LayoutElem, GridChild, GridElem, GridItem, HAlignment, HElem, HideElem, InlineElem, LayoutElem,
Length, MoveElem, OuterVAlignment, PadElem, PlaceElem, PlacementScope, Region, Rel, Length, MoveElem, OuterVAlignment, PadElem, PageElem, PlaceElem, PlacementScope,
RepeatElem, RotateElem, ScaleElem, Sides, Size, Sizing, SkewElem, Spacing, Region, Rel, RepeatElem, RotateElem, ScaleElem, Sides, Size, Sizing, SkewElem,
StackChild, StackElem, TrackSizings, VElem, Spacing, StackChild, StackElem, TrackSizings, VElem,
}; };
use typst_library::math::EquationElem; use typst_library::math::EquationElem;
use typst_library::model::{ use typst_library::model::{
@ -23,12 +22,12 @@ use typst_library::model::{
LinkElem, ListElem, Outlinable, OutlineElem, OutlineEntry, ParElem, ParbreakElem, LinkElem, ListElem, Outlinable, OutlineElem, OutlineEntry, ParElem, ParbreakElem,
QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, Works, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, Works,
}; };
use typst_library::pdf::EmbedElem; use typst_library::pdf::{ArtifactElem, EmbedElem, PdfMarkerTag};
use typst_library::text::{ use typst_library::text::{
DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName,
OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem,
SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, TextSize, UnderlineElem, SmartQuoteElem, SmartQuotes, SpaceElem, StrikeElem, SubElem, SuperElem, TextElem,
WeightDelta, TextSize, UnderlineElem, WeightDelta,
}; };
use typst_library::visualize::{ use typst_library::visualize::{
CircleElem, CurveElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, CircleElem, CurveElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem,
@ -46,6 +45,7 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Paged, LIST_RULE); rules.register(Paged, LIST_RULE);
rules.register(Paged, ENUM_RULE); rules.register(Paged, ENUM_RULE);
rules.register(Paged, TERMS_RULE); rules.register(Paged, TERMS_RULE);
rules.register(Paged, LINK_MARKER_RULE);
rules.register(Paged, LINK_RULE); rules.register(Paged, LINK_RULE);
rules.register(Paged, HEADING_RULE); rules.register(Paged, HEADING_RULE);
rules.register(Paged, FIGURE_RULE); rules.register(Paged, FIGURE_RULE);
@ -103,6 +103,8 @@ pub fn register(rules: &mut NativeRuleMap) {
// PDF. // PDF.
rules.register(Paged, EMBED_RULE); rules.register(Paged, EMBED_RULE);
rules.register(Paged, PDF_ARTIFACT_RULE);
rules.register(Paged, PDF_MARKER_TAG_RULE);
} }
const STRONG_RULE: ShowFn<StrongElem> = |elem, _, styles| { const STRONG_RULE: ShowFn<StrongElem> = |elem, _, styles| {
@ -172,9 +174,9 @@ const TERMS_RULE: ShowFn<TermsElem> = |elem, _, styles| {
for child in elem.children.iter() { for child in elem.children.iter() {
let mut seq = vec![]; let mut seq = vec![];
seq.extend(unpad.clone()); seq.extend(unpad.clone());
seq.push(child.term.clone().strong()); seq.push(PdfMarkerTag::ListItemLabel(child.term.clone().strong()));
seq.push(separator.clone()); seq.push(separator.clone());
seq.push(child.description.clone()); seq.push(PdfMarkerTag::ListItemBody(child.description.clone()));
// Text in wide term lists shall always turn into paragraphs. // Text in wide term lists shall always turn into paragraphs.
if !tight { if !tight {
@ -210,10 +212,16 @@ const TERMS_RULE: ShowFn<TermsElem> = |elem, _, styles| {
Ok(realized) Ok(realized)
}; };
const LINK_RULE: ShowFn<LinkElem> = |elem, engine, _| { const LINK_MARKER_RULE: ShowFn<LinkMarker> = |elem, _, _| Ok(elem.body.clone());
const LINK_RULE: ShowFn<LinkElem> = |elem, engine, styles| {
let body = elem.body.clone(); let body = elem.body.clone();
let dest = elem.dest.resolve(engine.introspector).at(elem.span())?; let dest = elem.dest.resolve(engine.introspector).at(elem.span())?;
Ok(body.linked(dest)) let alt = match elem.alt.get_cloned(styles) {
Some(alt) => Some(alt),
None => dest.alt_text(engine, styles)?,
};
Ok(body.linked(dest, alt))
}; };
const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| { const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| {
@ -254,7 +262,7 @@ const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| {
let spacing = HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack(); let spacing = HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack();
realized = numbering + spacing + realized; realized = PdfMarkerTag::Label(numbering) + spacing + realized;
} }
let block = if indent != Abs::zero() { let block = if indent != Abs::zero() {
@ -273,7 +281,7 @@ const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| {
const FIGURE_RULE: ShowFn<FigureElem> = |elem, _, styles| { const FIGURE_RULE: ShowFn<FigureElem> = |elem, _, styles| {
let span = elem.span(); let span = elem.span();
let mut realized = elem.body.clone(); let mut realized = PdfMarkerTag::FigureBody(elem.body.clone());
// Build the caption, if any. // Build the caption, if any.
if let Some(caption) = elem.caption.get_cloned(styles) { if let Some(caption) = elem.caption.get_cloned(styles) {
@ -372,10 +380,11 @@ const FOOTNOTE_RULE: ShowFn<FootnoteElem> = |elem, engine, styles| {
let numbering = elem.numbering.get_ref(styles); let numbering = elem.numbering.get_ref(styles);
let counter = Counter::of(FootnoteElem::ELEM); let counter = Counter::of(FootnoteElem::ELEM);
let num = counter.display_at_loc(engine, loc, styles, numbering)?; let num = counter.display_at_loc(engine, loc, styles, numbering)?;
let sup = SuperElem::new(num).pack().spanned(span); let alt = FootnoteElem::alt_text(styles, &num.plain_text());
let sup = PdfMarkerTag::Label(SuperElem::new(num).pack().spanned(span));
let loc = loc.variant(1); let loc = loc.variant(1);
// Add zero-width weak spacing to make the footnote "sticky". // Add zero-width weak spacing to make the footnote "sticky".
Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc))) Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc), Some(alt)))
}; };
const FOOTNOTE_ENTRY_RULE: ShowFn<FootnoteEntry> = |elem, engine, styles| { const FOOTNOTE_ENTRY_RULE: ShowFn<FootnoteEntry> = |elem, engine, styles| {
@ -392,10 +401,9 @@ const FOOTNOTE_ENTRY_RULE: ShowFn<FootnoteEntry> = |elem, engine, styles| {
}; };
let num = counter.display_at_loc(engine, loc, styles, numbering)?; let num = counter.display_at_loc(engine, loc, styles, numbering)?;
let sup = SuperElem::new(num) let alt = num.plain_text();
.pack() let sup = PdfMarkerTag::Label(SuperElem::new(num).pack().spanned(span))
.spanned(span) .linked(Destination::Location(loc), Some(alt))
.linked(Destination::Location(loc))
.located(loc.variant(1)); .located(loc.variant(1));
Ok(Content::sequence([ Ok(Content::sequence([
@ -426,6 +434,7 @@ const OUTLINE_RULE: ShowFn<OutlineElem> = |elem, engine, styles| {
let depth = elem.depth.get(styles).unwrap_or(NonZeroUsize::MAX); let depth = elem.depth.get(styles).unwrap_or(NonZeroUsize::MAX);
// Build the outline entries. // Build the outline entries.
let mut entries = vec![];
for elem in elems { for elem in elems {
let Some(outlinable) = elem.with::<dyn Outlinable>() else { let Some(outlinable) = elem.with::<dyn Outlinable>() else {
bail!(span, "cannot outline {}", elem.func().name()); bail!(span, "cannot outline {}", elem.func().name());
@ -434,10 +443,13 @@ const OUTLINE_RULE: ShowFn<OutlineElem> = |elem, engine, styles| {
let level = outlinable.level(); let level = outlinable.level();
if outlinable.outlined() && level <= depth { if outlinable.outlined() && level <= depth {
let entry = OutlineEntry::new(level, elem); let entry = OutlineEntry::new(level, elem);
seq.push(entry.pack().spanned(span)); entries.push(entry.pack().spanned(span));
} }
} }
// Wrap the entries into a marker for pdf tagging.
seq.push(PdfMarkerTag::OutlineBody(Content::sequence(entries)));
Ok(Content::sequence(seq)) Ok(Content::sequence(seq))
}; };
@ -447,7 +459,24 @@ const OUTLINE_ENTRY_RULE: ShowFn<OutlineEntry> = |elem, engine, styles| {
let context = context.track(); let context = context.track();
let prefix = elem.prefix(engine, context, span)?; let prefix = elem.prefix(engine, context, span)?;
let inner = elem.inner(engine, context, span)?; let body = elem.body().at(span)?;
let page = elem.page(engine, context, span)?;
let alt = {
let prefix = prefix.as_ref().map(|p| p.plain_text()).unwrap_or_default();
let body = body.plain_text();
let page_str = PageElem::local_name_in(styles);
let page_nr = page.plain_text();
let quotes = SmartQuotes::get(
styles.get_ref(SmartQuoteElem::quotes),
styles.get(TextElem::lang),
styles.get(TextElem::region),
styles.get(SmartQuoteElem::alternative),
);
let open = quotes.double_open;
let close = quotes.double_close;
eco_format!("{prefix} {open}{body}{close} {page_str} {page_nr}",)
};
let inner = elem.build_inner(context, span, body, page)?;
let block = if elem.element.is::<EquationElem>() { let block = if elem.element.is::<EquationElem>() {
let body = prefix.unwrap_or_default() + inner; let body = prefix.unwrap_or_default() + inner;
BlockElem::new() BlockElem::new()
@ -459,7 +488,7 @@ const OUTLINE_ENTRY_RULE: ShowFn<OutlineEntry> = |elem, engine, styles| {
}; };
let loc = elem.element_location().at(span)?; let loc = elem.element_location().at(span)?;
Ok(block.linked(Destination::Location(loc))) Ok(block.linked(Destination::Location(loc), Some(alt)))
}; };
const REF_RULE: ShowFn<RefElem> = |elem, engine, styles| elem.realize(engine, styles); const REF_RULE: ShowFn<RefElem> = |elem, engine, styles| elem.realize(engine, styles);
@ -507,25 +536,29 @@ const BIBLIOGRAPHY_RULE: ShowFn<BibliographyElem> = |elem, engine, styles| {
let mut cells = vec![]; let mut cells = vec![];
for (prefix, reference) in references { for (prefix, reference) in references {
let prefix = PdfMarkerTag::ListItemLabel(prefix.clone().unwrap_or_default());
cells.push(GridChild::Item(GridItem::Cell( cells.push(GridChild::Item(GridItem::Cell(
Packed::new(GridCell::new(prefix.clone().unwrap_or_default())) Packed::new(GridCell::new(prefix)).spanned(span),
.spanned(span),
))); )));
let reference = PdfMarkerTag::BibEntry(reference.clone());
cells.push(GridChild::Item(GridItem::Cell( cells.push(GridChild::Item(GridItem::Cell(
Packed::new(GridCell::new(reference.clone())).spanned(span), Packed::new(GridCell::new(reference)).spanned(span),
))); )));
} }
seq.push(
GridElem::new(cells) let grid = GridElem::new(cells)
.with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) .with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
.with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
.with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) .with_row_gutter(TrackSizings(smallvec![row_gutter.into()]))
.pack() .pack()
.spanned(span), .spanned(span);
); // TODO(accessibility): infer list numbering from style?
seq.push(PdfMarkerTag::Bibliography(true, grid));
} else { } else {
let mut body = vec![];
for (_, reference) in references { for (_, reference) in references {
let realized = reference.clone(); let realized = PdfMarkerTag::BibEntry(reference.clone());
let block = if works.hanging_indent { let block = if works.hanging_indent {
let body = HElem::new((-INDENT).into()).pack() + realized; let body = HElem::new((-INDENT).into()).pack() + realized;
let inset = Sides::default() let inset = Sides::default()
@ -537,8 +570,9 @@ const BIBLIOGRAPHY_RULE: ShowFn<BibliographyElem> = |elem, engine, styles| {
BlockElem::new().with_body(Some(BlockBody::Content(realized))) BlockElem::new().with_body(Some(BlockBody::Content(realized)))
}; };
seq.push(block.pack().spanned(span)); body.push(block.pack().spanned(span));
} }
seq.push(PdfMarkerTag::Bibliography(false, Content::sequence(body)));
} }
Ok(Content::sequence(seq)) Ok(Content::sequence(seq))
@ -840,3 +874,7 @@ const EQUATION_RULE: ShowFn<EquationElem> = |elem, _, styles| {
}; };
const EMBED_RULE: ShowFn<EmbedElem> = |_, _, _| Ok(Content::empty()); const EMBED_RULE: ShowFn<EmbedElem> = |_, _, _| Ok(Content::empty());
const PDF_ARTIFACT_RULE: ShowFn<ArtifactElem> = |elem, _, _| Ok(elem.body.clone());
const PDF_MARKER_TAG_RULE: ShowFn<PdfMarkerTag> = |elem, _, _| Ok(elem.body.clone());

View File

@ -23,15 +23,16 @@ use serde::{Serialize, Serializer};
use typst_syntax::Span; use typst_syntax::Span;
use typst_utils::singleton; use typst_utils::singleton;
use crate::diag::{SourceResult, StrResult}; use crate::diag::{SourceResult, StrResult, bail};
use crate::engine::Engine; use crate::engine::Engine;
use crate::foundations::{ use crate::foundations::{
Context, Dict, IntoValue, Label, Property, Recipe, RecipeIndex, Repr, Selector, Str, Args, Context, Dict, IntoValue, Label, Property, Recipe, RecipeIndex, Repr, Selector,
Style, StyleChain, Styles, Value, func, repr, scope, ty, Str, Style, StyleChain, Styles, Value, func, repr, scope, ty,
}; };
use crate::introspection::Location; use crate::introspection::{Locatable, Location};
use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides};
use crate::model::{Destination, EmphElem, LinkElem, StrongElem}; use crate::model::{Destination, EmphElem, LinkElem, StrongElem};
use crate::pdf::{ArtifactElem, ArtifactKind};
use crate::text::UnderlineElem; use crate::text::UnderlineElem;
/// A piece of document content. /// A piece of document content.
@ -476,8 +477,12 @@ impl Content {
} }
/// Link the content somewhere. /// Link the content somewhere.
pub fn linked(self, dest: Destination) -> Self { pub fn linked(self, dest: Destination, alt: Option<EcoString>) -> Self {
self.set(LinkElem::current, Some(dest)) let span = self.span();
LinkMarker::new(self, dest.clone(), alt, span)
.pack()
.spanned(span)
.set(LinkElem::current, Some(dest))
} }
/// Set alignments for this content. /// Set alignments for this content.
@ -506,6 +511,12 @@ impl Content {
.pack() .pack()
.spanned(span) .spanned(span)
} }
/// Link the content somewhere.
pub fn artifact(self, kind: ArtifactKind) -> Self {
let span = self.span();
ArtifactElem::new(self).with_kind(kind).pack().spanned(span)
}
} }
#[scope] #[scope]
@ -773,6 +784,30 @@ impl Repr for StyledElem {
} }
} }
/// An element that associates the body of a link with the destination.
#[elem(Locatable, Construct)]
pub struct LinkMarker {
/// The content.
#[internal]
#[required]
pub body: Content,
#[internal]
#[required]
pub dest: Destination,
#[internal]
#[required]
pub alt: Option<EcoString>,
#[internal]
#[required]
pub span: Span,
}
impl Construct for LinkMarker {
fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
bail!(args.span, "cannot be constructed manually");
}
}
impl<T: NativeElement> IntoValue for T { impl<T: NativeElement> IntoValue for T {
fn into_value(self) -> Value { fn into_value(self) -> Value {
Value::Content(self.pack()) Value::Content(self.pack())

View File

@ -13,6 +13,7 @@ use crate::foundations::{
Array, CastInfo, Content, Context, Fold, FromValue, Func, IntoValue, Packed, Reflect, Array, CastInfo, Content, Context, Fold, FromValue, Func, IntoValue, Packed, Reflect,
Resolve, Smart, StyleChain, Value, cast, elem, scope, Resolve, Smart, StyleChain, Value, cast, elem, scope,
}; };
use crate::introspection::Locatable;
use crate::layout::{ use crate::layout::{
Alignment, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing, Alignment, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing,
}; };
@ -136,7 +137,7 @@ use crate::visualize::{Paint, Stroke};
/// ///
/// Furthermore, strokes of a repeated grid header or footer will take /// Furthermore, strokes of a repeated grid header or footer will take
/// precedence over regular cell strokes. /// precedence over regular cell strokes.
#[elem(scope)] #[elem(scope, Locatable)]
pub struct GridElem { pub struct GridElem {
/// The column sizes. /// The column sizes.
/// ///
@ -640,7 +641,7 @@ pub struct GridVLine {
/// which allows you, for example, to apply styles based on a cell's position. /// which allows you, for example, to apply styles based on a cell's position.
/// Refer to the examples of the [`table.cell`]($table.cell) element to learn /// Refer to the examples of the [`table.cell`]($table.cell) element to learn
/// more about this. /// more about this.
#[elem(name = "cell", title = "Grid Cell")] #[elem(name = "cell", title = "Grid Cell", Locatable)]
pub struct GridCell { pub struct GridCell {
/// The cell's body. /// The cell's body.
#[required] #[required]

View File

@ -22,6 +22,7 @@ use typst_syntax::Span;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use crate::introspection::SplitLocator; use crate::introspection::SplitLocator;
use crate::pdf::{TableCellKind, TableHeaderScope};
/// Convert a grid to a cell grid. /// Convert a grid to a cell grid.
#[typst_macros::time(span = elem.span())] #[typst_macros::time(span = elem.span())]
@ -217,6 +218,7 @@ impl ResolvableCell for Packed<TableCell> {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
kind: Smart<TableCellKind>,
) -> Cell<'a> { ) -> Cell<'a> {
let cell = &mut *self; let cell = &mut *self;
let colspan = cell.colspan.get(styles); let colspan = cell.colspan.get(styles);
@ -224,6 +226,8 @@ impl ResolvableCell for Packed<TableCell> {
let breakable = cell.breakable.get(styles).unwrap_or(breakable); let breakable = cell.breakable.get(styles).unwrap_or(breakable);
let fill = cell.fill.get_cloned(styles).unwrap_or_else(|| fill.clone()); let fill = cell.fill.get_cloned(styles).unwrap_or_else(|| fill.clone());
let kind = cell.kind.get(styles).or(kind);
let cell_stroke = cell.stroke.resolve(styles); let cell_stroke = cell.stroke.resolve(styles);
let stroke_overridden = let stroke_overridden =
cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
@ -267,6 +271,7 @@ impl ResolvableCell for Packed<TableCell> {
}), }),
); );
cell.breakable.set(Smart::Custom(breakable)); cell.breakable.set(Smart::Custom(breakable));
cell.kind.set(kind);
Cell { Cell {
body: self.pack(), body: self.pack(),
locator, locator,
@ -312,6 +317,7 @@ impl ResolvableCell for Packed<GridCell> {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
_: Smart<TableCellKind>,
) -> Cell<'a> { ) -> Cell<'a> {
let cell = &mut *self; let cell = &mut *self;
let colspan = cell.colspan.get(styles); let colspan = cell.colspan.get(styles);
@ -518,6 +524,7 @@ pub trait ResolvableCell {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
kind: Smart<TableCellKind>,
) -> Cell<'a>; ) -> Cell<'a>;
/// Returns this cell's column override. /// Returns this cell's column override.
@ -1194,8 +1201,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// a non-empty row. // a non-empty row.
let mut first_available_row = 0; let mut first_available_row = 0;
// The cell kind is currently only used for tagged PDF.
let cell_kind;
let (header_footer_items, simple_item) = match child { let (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, level, span, items, .. } => { ResolvableGridChild::Header { repeat, level, span, items } => {
cell_kind =
Smart::Custom(TableCellKind::Header(level, TableHeaderScope::Column));
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
@ -1222,11 +1235,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Footer { repeat, span, items, .. } => { ResolvableGridChild::Footer { repeat, span, items } => {
if footer.is_some() { if footer.is_some() {
bail!(span, "cannot have more than one footer"); bail!(span, "cannot have more than one footer");
} }
cell_kind = Smart::Custom(TableCellKind::Footer);
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
@ -1245,6 +1260,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Item(item) => { ResolvableGridChild::Item(item) => {
cell_kind = Smart::Custom(TableCellKind::Data);
if matches!(item, ResolvableGridItem::Cell(_)) { if matches!(item, ResolvableGridItem::Cell(_)) {
*at_least_one_cell = true; *at_least_one_cell = true;
} }
@ -1435,7 +1452,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// Let's resolve the cell so it can determine its own fields // Let's resolve the cell so it can determine its own fields
// based on its final position. // based on its final position.
let cell = self.resolve_cell(cell, x, y, rowspan, cell_span)?; let cell = self.resolve_cell(cell, x, y, rowspan, cell_span, cell_kind)?;
if largest_index >= resolved_cells.len() { if largest_index >= resolved_cells.len() {
// Ensure the length of the vector of resolved cells is // Ensure the length of the vector of resolved cells is
@ -1530,6 +1547,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// and footers without having to loop through them each time. // and footers without having to loop through them each time.
// Cells themselves, unfortunately, still have to. // Cells themselves, unfortunately, still have to.
assert!(resolved_cells[*local_auto_index].is_none()); assert!(resolved_cells[*local_auto_index].is_none());
let kind = match row_group.kind {
RowGroupKind::Header => TableCellKind::Header(
NonZeroU32::ONE,
TableHeaderScope::default(),
),
RowGroupKind::Footer => TableCellKind::Footer,
};
resolved_cells[*local_auto_index] = resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell( Some(Entry::Cell(self.resolve_cell(
T::default(), T::default(),
@ -1537,6 +1561,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
first_available_row, first_available_row,
1, 1,
Span::detached(), Span::detached(),
Smart::Custom(kind),
)?)); )?));
group_start..group_end group_start..group_end
@ -1661,6 +1686,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
y, y,
1, 1,
Span::detached(), Span::detached(),
Smart::Auto,
)?)) )?))
} }
}) })
@ -1906,6 +1932,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
y: usize, y: usize,
rowspan: usize, rowspan: usize,
cell_span: Span, cell_span: Span,
kind: Smart<TableCellKind>,
) -> SourceResult<Cell<'x>> ) -> SourceResult<Cell<'x>>
where where
T: ResolvableCell + Default, T: ResolvableCell + Default,
@ -1942,6 +1969,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
breakable, breakable,
self.locator.next(&cell_span), self.locator.next(&cell_span),
self.styles, self.styles,
kind,
)) ))
} }
} }

View File

@ -1,4 +1,5 @@
use crate::foundations::{Content, elem}; use crate::foundations::{Content, elem};
use crate::introspection::Locatable;
/// Hides content without affecting layout. /// Hides content without affecting layout.
/// ///
@ -12,7 +13,7 @@ use crate::foundations::{Content, elem};
/// Hello Jane \ /// Hello Jane \
/// #hide[Hello] Joe /// #hide[Hello] Joe
/// ``` /// ```
#[elem] #[elem(Locatable)]
pub struct HideElem { pub struct HideElem {
/// The content to hide. /// The content to hide.
#[required] #[required]

View File

@ -1,4 +1,5 @@
use crate::foundations::{Content, elem}; use crate::foundations::{Content, elem};
use crate::introspection::Locatable;
use crate::layout::Length; use crate::layout::Length;
/// Repeats content to the available space. /// Repeats content to the available space.
@ -22,7 +23,7 @@ use crate::layout::Length;
/// Berlin, the 22nd of December, 2022 /// Berlin, the 22nd of December, 2022
/// ] /// ]
/// ``` /// ```
#[elem] #[elem(Locatable)]
pub struct RepeatElem { pub struct RepeatElem {
/// The content to repeat. /// The content to repeat.
#[required] #[required]

View File

@ -1,6 +1,7 @@
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use codex::styling::MathVariant; use codex::styling::MathVariant;
use ecow::EcoString;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use unicode_math_class::MathClass; use unicode_math_class::MathClass;
@ -47,6 +48,9 @@ use crate::text::{FontFamily, FontList, FontWeight, LocalName, TextElem};
/// [main math page]($category/math). /// [main math page]($category/math).
#[elem(Locatable, Synthesize, ShowSet, Count, LocalName, Refable, Outlinable)] #[elem(Locatable, Synthesize, ShowSet, Count, LocalName, Refable, Outlinable)]
pub struct EquationElem { pub struct EquationElem {
/// An alternative description of the mathematical equation.
pub alt: Option<EcoString>,
/// Whether the equation is displayed as a separate block. /// Whether the equation is displayed as a separate block.
#[default(false)] #[default(false)]
pub block: bool, pub block: bool,

View File

@ -795,7 +795,8 @@ impl<'a> Generator<'a> {
renderer.display_elem_child(elem, &mut None, false)?; renderer.display_elem_child(elem, &mut None, false)?;
if let Some(location) = first_occurrences.get(item.key.as_str()) { if let Some(location) = first_occurrences.get(item.key.as_str()) {
let dest = Destination::Location(*location); let dest = Destination::Location(*location);
content = content.linked(dest); let alt = content.plain_text();
content = content.linked(dest, Some(alt));
} }
StrResult::Ok(content) StrResult::Ok(content)
}) })
@ -930,8 +931,9 @@ impl ElemRenderer<'_> {
if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta
&& let Some(location) = (self.link)(i) && let Some(location) = (self.link)(i)
{ {
let alt = content.plain_text();
let dest = Destination::Location(location); let dest = Destination::Location(location);
content = content.linked(dest); content = content.linked(dest, Some(alt));
} }
Ok(content) Ok(content)

View File

@ -42,7 +42,7 @@ use crate::text::{Lang, Region, TextElem};
/// This function indirectly has dedicated syntax. [References]($ref) can be /// This function indirectly has dedicated syntax. [References]($ref) can be
/// used to cite works from the bibliography. The label then corresponds to the /// used to cite works from the bibliography. The label then corresponds to the
/// citation key. /// citation key.
#[elem(Synthesize)] #[elem(Locatable, Synthesize)]
pub struct CiteElem { pub struct CiteElem {
/// The citation key that identifies the entry in the bibliography that /// The citation key that identifies the entry in the bibliography that
/// shall be cited, as a label. /// shall be cited, as a label.

View File

@ -1,4 +1,5 @@
use crate::foundations::{Content, elem}; use crate::foundations::{Content, elem};
use crate::introspection::Locatable;
/// Emphasizes content by toggling italics. /// Emphasizes content by toggling italics.
/// ///
@ -23,7 +24,7 @@ use crate::foundations::{Content, elem};
/// This function also has dedicated syntax: To emphasize content, simply /// This function also has dedicated syntax: To emphasize content, simply
/// enclose it in underscores (`_`). Note that this only works at word /// enclose it in underscores (`_`). Note that this only works at word
/// boundaries. To emphasize part of a word, you have to use the function. /// boundaries. To emphasize part of a word, you have to use the function.
#[elem(title = "Emphasis", keywords = ["italic"])] #[elem(title = "Emphasis", keywords = ["italic"], Locatable)]
pub struct EmphElem { pub struct EmphElem {
/// The content to emphasize. /// The content to emphasize.
#[required] #[required]

View File

@ -4,6 +4,7 @@ use smallvec::SmallVec;
use crate::diag::bail; use crate::diag::bail;
use crate::foundations::{Array, Content, Packed, Smart, Styles, cast, elem, scope}; use crate::foundations::{Array, Content, Packed, Smart, Styles, cast, elem, scope};
use crate::introspection::Locatable;
use crate::layout::{Alignment, Em, HAlignment, Length, VAlignment}; use crate::layout::{Alignment, Em, HAlignment, Length, VAlignment};
use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern};
@ -63,7 +64,7 @@ use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern};
/// Enumeration items can contain multiple paragraphs and other block-level /// Enumeration items can contain multiple paragraphs and other block-level
/// content. All content that is indented more than an item's marker becomes /// content. All content that is indented more than an item's marker becomes
/// part of that item. /// part of that item.
#[elem(scope, title = "Numbered List")] #[elem(scope, title = "Numbered List", Locatable)]
pub struct EnumElem { pub struct EnumElem {
/// Defines the default [spacing]($enum.spacing) of the enumeration. If it /// Defines the default [spacing]($enum.spacing) of the enumeration. If it
/// is `{false}`, the items are spaced apart with /// is `{false}`, the items are spaced apart with
@ -216,7 +217,7 @@ impl EnumElem {
} }
/// An enumeration item. /// An enumeration item.
#[elem(name = "item", title = "Numbered List Item")] #[elem(name = "item", title = "Numbered List Item", Locatable)]
pub struct EnumItem { pub struct EnumItem {
/// The item's number. /// The item's number.
#[positional] #[positional]

View File

@ -409,7 +409,7 @@ impl Outlinable for Packed<FigureElem> {
/// caption: [A rectangle], /// caption: [A rectangle],
/// ) /// )
/// ``` /// ```
#[elem(name = "caption", Synthesize)] #[elem(name = "caption", Locatable, Synthesize)]
pub struct FigureCaption { pub struct FigureCaption {
/// The caption's position in the figure. Either `{top}` or `{bottom}`. /// The caption's position in the figure. Either `{top}` or `{bottom}`.
/// ///

View File

@ -1,6 +1,7 @@
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::str::FromStr; use std::str::FromStr;
use ecow::{EcoString, eco_format};
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use crate::diag::{StrResult, bail}; use crate::diag::{StrResult, bail};
@ -12,7 +13,7 @@ use crate::foundations::{
use crate::introspection::{Count, CounterUpdate, Locatable, Location}; use crate::introspection::{Count, CounterUpdate, Locatable, Location};
use crate::layout::{Abs, Em, Length, Ratio}; use crate::layout::{Abs, Em, Length, Ratio};
use crate::model::{Numbering, NumberingPattern, ParElem}; use crate::model::{Numbering, NumberingPattern, ParElem};
use crate::text::{TextElem, TextSize}; use crate::text::{LocalName, TextElem, TextSize};
use crate::visualize::{LineElem, Stroke}; use crate::visualize::{LineElem, Stroke};
/// A footnote. /// A footnote.
@ -82,7 +83,16 @@ impl FootnoteElem {
type FootnoteEntry; type FootnoteEntry;
} }
impl LocalName for Packed<FootnoteElem> {
const KEY: &'static str = "footnote";
}
impl FootnoteElem { impl FootnoteElem {
pub fn alt_text(styles: StyleChain, num: &str) -> EcoString {
let local_name = Packed::<FootnoteElem>::local_name_in(styles);
eco_format!("{local_name} {num}")
}
/// Creates a new footnote that the passed content as its body. /// Creates a new footnote that the passed content as its body.
pub fn with_content(content: Content) -> Self { pub fn with_content(content: Content) -> Self {
Self::new(FootnoteBody::Content(content)) Self::new(FootnoteBody::Content(content))
@ -176,7 +186,7 @@ cast! {
/// page run is a sequence of pages without an explicit pagebreak in between). /// page run is a sequence of pages without an explicit pagebreak in between).
/// For this reason, set and show rules for footnote entries should be defined /// For this reason, set and show rules for footnote entries should be defined
/// before any page content, typically at the very start of the document. /// before any page content, typically at the very start of the document.
#[elem(name = "entry", title = "Footnote Entry", ShowSet)] #[elem(name = "entry", title = "Footnote Entry", Locatable, ShowSet)]
pub struct FootnoteEntry { pub struct FootnoteEntry {
/// The footnote for this entry. Its location can be used to determine /// The footnote for this entry. Its location can be used to determine
/// the footnote counter state. /// the footnote counter state.

View File

@ -1,15 +1,18 @@
use std::ops::Deref; use std::ops::Deref;
use std::str::FromStr;
use comemo::Tracked; use comemo::Tracked;
use ecow::{EcoString, eco_format}; use ecow::{EcoString, eco_format};
use crate::diag::{StrResult, bail}; use crate::diag::{SourceResult, StrResult, bail};
use crate::engine::Engine;
use crate::foundations::{ use crate::foundations::{
Content, Label, Packed, Repr, ShowSet, Smart, StyleChain, Styles, cast, elem, Content, Label, Packed, Repr, ShowSet, Smart, StyleChain, Styles, cast, elem,
}; };
use crate::introspection::{Introspector, Locatable, Location}; use crate::introspection::{Counter, CounterKey, Introspector, Locatable, Location};
use crate::layout::Position; use crate::layout::{PageElem, Position};
use crate::text::TextElem; use crate::model::NumberingPattern;
use crate::text::{LocalName, TextElem};
/// Links to a URL or a location in the document. /// Links to a URL or a location in the document.
/// ///
@ -85,6 +88,9 @@ use crate::text::TextElem;
/// generated. /// generated.
#[elem(Locatable)] #[elem(Locatable)]
pub struct LinkElem { pub struct LinkElem {
/// An alternative description of the link.
pub alt: Option<EcoString>,
/// The destination the link points to. /// The destination the link points to.
/// ///
/// - To link to web pages, `dest` should be a valid URL string. If the URL /// - To link to web pages, `dest` should be a valid URL string. If the URL
@ -212,7 +218,29 @@ pub enum Destination {
Location(Location), Location(Location),
} }
impl Destination {} impl Destination {
pub fn alt_text(
&self,
engine: &mut Engine,
styles: StyleChain,
) -> SourceResult<Option<EcoString>> {
let alt = match self {
Destination::Url(url) => Some(url.clone().into_inner()),
Destination::Position(_) => None,
&Destination::Location(loc) => {
let numbering = loc
.page_numbering(engine)
.unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into());
let content = Counter::new(CounterKey::Page)
.display_at_loc(engine, loc, styles, &numbering)?;
let page_nr = content.plain_text();
let page_str = PageElem::local_name_in(styles);
Some(eco_format!("{page_str} {page_nr}"))
}
};
Ok(alt)
}
}
impl Repr for Destination { impl Repr for Destination {
fn repr(&self) -> EcoString { fn repr(&self) -> EcoString {

View File

@ -6,6 +6,7 @@ use crate::foundations::{
Array, Content, Context, Depth, Func, NativeElement, Packed, Smart, StyleChain, Array, Content, Context, Depth, Func, NativeElement, Packed, Smart, StyleChain,
Styles, Value, cast, elem, scope, Styles, Value, cast, elem, scope,
}; };
use crate::introspection::Locatable;
use crate::layout::{Em, Length}; use crate::layout::{Em, Length};
use crate::text::TextElem; use crate::text::TextElem;
@ -40,7 +41,7 @@ use crate::text::TextElem;
/// followed by a space to create a list item. A list item can contain multiple /// followed by a space to create a list item. A list item can contain multiple
/// paragraphs and other block-level content. All content that is indented /// paragraphs and other block-level content. All content that is indented
/// more than an item's marker becomes part of that item. /// more than an item's marker becomes part of that item.
#[elem(scope, title = "Bullet List")] #[elem(scope, title = "Bullet List", Locatable)]
pub struct ListElem { pub struct ListElem {
/// Defines the default [spacing]($list.spacing) of the list. If it is /// Defines the default [spacing]($list.spacing) of the list. If it is
/// `{false}`, the items are spaced apart with /// `{false}`, the items are spaced apart with
@ -135,7 +136,7 @@ impl ListElem {
} }
/// A bullet list item. /// A bullet list item.
#[elem(name = "item", title = "Bullet List Item")] #[elem(name = "item", title = "Bullet List Item", Locatable)]
pub struct ListItem { pub struct ListItem {
/// The item's body. /// The item's body.
#[required] #[required]

View File

@ -20,6 +20,7 @@ use crate::layout::{
RepeatElem, Sides, RepeatElem, Sides,
}; };
use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable}; use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable};
use crate::pdf::PdfMarkerTag;
use crate::text::{LocalName, SpaceElem, TextElem}; use crate::text::{LocalName, SpaceElem, TextElem};
/// A table of contents, figures, or other elements. /// A table of contents, figures, or other elements.
@ -323,7 +324,7 @@ pub trait Outlinable: Refable {
/// With show-set and show rules on outline entries, you can richly customize /// With show-set and show rules on outline entries, you can richly customize
/// the outline's appearance. See the /// the outline's appearance. See the
/// [section on styling the outline]($outline/#styling-the-outline) for details. /// [section on styling the outline]($outline/#styling-the-outline) for details.
#[elem(scope, name = "entry", title = "Outline Entry")] #[elem(scope, name = "entry", title = "Outline Entry", Locatable)]
pub struct OutlineEntry { pub struct OutlineEntry {
/// The nesting level of this outline entry. Starts at `{1}` for top-level /// The nesting level of this outline entry. Starts at `{1}` for top-level
/// entries. /// entries.
@ -492,7 +493,7 @@ impl OutlineEntry {
let styles = context.styles().at(span)?; let styles = context.styles().at(span)?;
let numbers = let numbers =
outlinable.counter().display_at_loc(engine, loc, styles, numbering)?; outlinable.counter().display_at_loc(engine, loc, styles, numbering)?;
Ok(Some(outlinable.prefix(numbers))) Ok(Some(PdfMarkerTag::Label(outlinable.prefix(numbers))))
} }
/// Creates the default inner content of the entry. /// Creates the default inner content of the entry.
@ -505,53 +506,9 @@ impl OutlineEntry {
context: Tracked<Context>, context: Tracked<Context>,
span: Span, span: Span,
) -> SourceResult<Content> { ) -> SourceResult<Content> {
let styles = context.styles().at(span)?; let body = self.body().at(span)?;
let page = self.page(engine, context, span)?;
let mut seq = vec![]; self.build_inner(context, span, body, page)
// Isolate the entry body in RTL because the page number is typically
// LTR. I'm not sure whether LTR should conceptually also be isolated,
// but in any case we don't do it for now because the text shaping
// pipeline does tend to choke a bit on default ignorables (in
// particular the CJK-Latin spacing).
//
// See also:
// - https://github.com/typst/typst/issues/4476
// - https://github.com/typst/typst/issues/5176
let rtl = styles.resolve(TextElem::dir) == Dir::RTL;
if rtl {
// "Right-to-Left Embedding"
seq.push(TextElem::packed("\u{202B}"));
}
seq.push(self.body().at(span)?);
if rtl {
// "Pop Directional Formatting"
seq.push(TextElem::packed("\u{202C}"));
}
// Add the filler between the section name and page number.
if let Some(filler) = self.fill.get_cloned(styles) {
seq.push(SpaceElem::shared().clone());
seq.push(
BoxElem::new()
.with_body(Some(filler))
.with_width(Fr::one().into())
.pack()
.spanned(span),
);
seq.push(SpaceElem::shared().clone());
} else {
seq.push(HElem::new(Fr::one().into()).pack().spanned(span));
}
// Add the page number. The word joiner in front ensures that the page
// number doesn't stand alone in its line.
seq.push(TextElem::packed("\u{2060}"));
seq.push(self.page(engine, context, span)?);
Ok(Content::sequence(seq))
} }
/// The content which is displayed in place of the referred element at its /// The content which is displayed in place of the referred element at its
@ -584,6 +541,62 @@ impl OutlineEntry {
} }
impl OutlineEntry { impl OutlineEntry {
pub fn build_inner(
&self,
context: Tracked<Context>,
span: Span,
body: Content,
page: Content,
) -> SourceResult<Content> {
let styles = context.styles().at(span)?;
let mut seq = vec![];
// Isolate the entry body in RTL because the page number is typically
// LTR. I'm not sure whether LTR should conceptually also be isolated,
// but in any case we don't do it for now because the text shaping
// pipeline does tend to choke a bit on default ignorables (in
// particular the CJK-Latin spacing).
//
// See also:
// - https://github.com/typst/typst/issues/4476
// - https://github.com/typst/typst/issues/5176
let rtl = styles.resolve(TextElem::dir) == Dir::RTL;
if rtl {
// "Right-to-Left Embedding"
seq.push(TextElem::packed("\u{202B}"));
}
seq.push(body);
if rtl {
// "Pop Directional Formatting"
seq.push(TextElem::packed("\u{202C}"));
}
// Add the filler between the section name and page number.
if let Some(filler) = self.fill.get_cloned(styles) {
seq.push(SpaceElem::shared().clone());
seq.push(
BoxElem::new()
.with_body(Some(filler))
.with_width(Fr::one().into())
.pack()
.spanned(span),
);
seq.push(SpaceElem::shared().clone());
} else {
seq.push(HElem::new(Fr::one().into()).pack().spanned(span));
}
// Add the page number. The word joiner in front ensures that the page
// number doesn't stand alone in its line.
seq.push(TextElem::packed("\u{2060}"));
seq.push(page);
Ok(Content::sequence(seq))
}
fn outlinable(&self) -> StrResult<&dyn Outlinable> { fn outlinable(&self) -> StrResult<&dyn Outlinable> {
self.element self.element
.with::<dyn Outlinable>() .with::<dyn Outlinable>()

View File

@ -93,7 +93,7 @@ use crate::model::Numbering;
/// let $a$ be the smallest of the /// let $a$ be the smallest of the
/// three integers. Then, we ... /// three integers. Then, we ...
/// ``` /// ```
#[elem(scope, title = "Paragraph")] #[elem(scope, title = "Paragraph", Locatable)]
pub struct ParElem { pub struct ParElem {
/// The spacing between lines. /// The spacing between lines.
/// ///

View File

@ -342,6 +342,12 @@ fn realize_reference(
Smart::Custom(Some(supplement)) => supplement.resolve(engine, styles, [elem])?, Smart::Custom(Some(supplement)) => supplement.resolve(engine, styles, [elem])?,
}; };
let alt = {
let supplement = supplement.plain_text();
let numbering = numbers.plain_text();
eco_format!("{supplement} {numbering}",)
};
let mut content = numbers; let mut content = numbers;
if !supplement.is_empty() { if !supplement.is_empty() {
content = supplement + TextElem::packed("\u{a0}") + content; content = supplement + TextElem::packed("\u{a0}") + content;
@ -353,7 +359,7 @@ fn realize_reference(
// TODO: We should probably also use `LinkElem` in the paged target, but // TODO: We should probably also use `LinkElem` in the paged target, but
// it's a bit breaking and it becomes hard to style links without // it's a bit breaking and it becomes hard to style links without
// affecting references, so this change should be well-considered. // affecting references, so this change should be well-considered.
content.linked(Destination::Location(loc)) content.linked(Destination::Location(loc), Some(alt))
}) })
} }

View File

@ -1,4 +1,5 @@
use crate::foundations::{Content, elem}; use crate::foundations::{Content, elem};
use crate::introspection::Locatable;
/// Strongly emphasizes content by increasing the font weight. /// Strongly emphasizes content by increasing the font weight.
/// ///
@ -18,7 +19,7 @@ use crate::foundations::{Content, elem};
/// simply enclose it in stars/asterisks (`*`). Note that this only works at /// simply enclose it in stars/asterisks (`*`). Note that this only works at
/// word boundaries. To strongly emphasize part of a word, you have to use the /// word boundaries. To strongly emphasize part of a word, you have to use the
/// function. /// function.
#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"])] #[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Locatable)]
pub struct StrongElem { pub struct StrongElem {
/// The delta to apply on the font weight. /// The delta to apply on the font weight.
/// ///

View File

@ -1,15 +1,18 @@
use std::num::{NonZeroU32, NonZeroUsize}; use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc; use std::sync::Arc;
use ecow::EcoString;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use crate::diag::{HintedStrResult, HintedString, bail}; use crate::diag::{HintedStrResult, HintedString, bail};
use crate::foundations::{Content, Packed, Smart, cast, elem, scope}; use crate::foundations::{Content, Packed, Smart, cast, elem, scope};
use crate::introspection::Locatable;
use crate::layout::{ use crate::layout::{
Abs, Alignment, Celled, GridCell, GridFooter, GridHLine, GridHeader, GridVLine, Abs, Alignment, Celled, GridCell, GridFooter, GridHLine, GridHeader, GridVLine,
Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings,
}; };
use crate::model::Figurable; use crate::model::Figurable;
use crate::pdf::TableCellKind;
use crate::text::LocalName; use crate::text::LocalName;
use crate::visualize::{Paint, Stroke}; use crate::visualize::{Paint, Stroke};
@ -113,7 +116,7 @@ use crate::visualize::{Paint, Stroke};
/// [Robert], b, a, b, /// [Robert], b, a, b,
/// ) /// )
/// ``` /// ```
#[elem(scope, LocalName, Figurable)] #[elem(scope, Locatable, LocalName, Figurable)]
pub struct TableElem { pub struct TableElem {
/// The column sizes. See the [grid documentation]($grid) for more /// The column sizes. See the [grid documentation]($grid) for more
/// information on track sizing. /// information on track sizing.
@ -222,6 +225,9 @@ pub struct TableElem {
#[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))] #[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))]
pub inset: Celled<Sides<Option<Rel<Length>>>>, pub inset: Celled<Sides<Option<Rel<Length>>>>,
// TODO: docs
pub summary: Option<EcoString>,
/// The contents of the table cells, plus any extra table lines specified /// The contents of the table cells, plus any extra table lines specified
/// with the [`table.hline`]($table.hline) and /// with the [`table.hline`]($table.hline) and
/// [`table.vline`]($table.vline) elements. /// [`table.vline`]($table.vline) elements.
@ -646,7 +652,7 @@ pub struct TableVLine {
/// [Vikram], [49], [Perseverance], /// [Vikram], [49], [Perseverance],
/// ) /// )
/// ``` /// ```
#[elem(name = "cell", title = "Table Cell")] #[elem(name = "cell", title = "Table Cell", Locatable)]
pub struct TableCell { pub struct TableCell {
/// The cell's body. /// The cell's body.
#[required] #[required]
@ -681,6 +687,10 @@ pub struct TableCell {
#[fold] #[fold]
pub stroke: Sides<Option<Option<Arc<Stroke>>>>, pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
#[internal]
#[parse(Some(Smart::Auto))]
pub kind: Smart<TableCellKind>,
/// Whether rows spanned by this cell can be placed in different pages. /// Whether rows spanned by this cell can be placed in different pages.
/// When equal to `{auto}`, a cell spanning only fixed-size rows is /// When equal to `{auto}`, a cell spanning only fixed-size rows is
/// unbreakable, while a cell spanning at least one `{auto}`-sized row is /// unbreakable, while a cell spanning at least one `{auto}`-sized row is

View File

@ -2,6 +2,7 @@ use crate::diag::bail;
use crate::foundations::{ use crate::foundations::{
Array, Content, NativeElement, Packed, Smart, Styles, cast, elem, scope, Array, Content, NativeElement, Packed, Smart, Styles, cast, elem, scope,
}; };
use crate::introspection::Locatable;
use crate::layout::{Em, HElem, Length}; use crate::layout::{Em, HElem, Length};
use crate::model::{ListItemLike, ListLike}; use crate::model::{ListItemLike, ListLike};
@ -21,7 +22,7 @@ use crate::model::{ListItemLike, ListLike};
/// # Syntax /// # Syntax
/// This function also has dedicated syntax: Starting a line with a slash, /// This function also has dedicated syntax: Starting a line with a slash,
/// followed by a term, a colon and a description creates a term list item. /// followed by a term, a colon and a description creates a term list item.
#[elem(scope, title = "Term List")] #[elem(scope, title = "Term List", Locatable)]
pub struct TermsElem { pub struct TermsElem {
/// Defines the default [spacing]($terms.spacing) of the term list. If it is /// Defines the default [spacing]($terms.spacing) of the term list. If it is
/// `{false}`, the items are spaced apart with /// `{false}`, the items are spaced apart with
@ -112,7 +113,7 @@ impl TermsElem {
} }
/// A term list item. /// A term list item.
#[elem(name = "item", title = "Term List Item")] #[elem(name = "item", title = "Term List Item", Locatable)]
pub struct TermItem { pub struct TermItem {
/// The term described by the list item. /// The term described by the list item.
#[required] #[required]

View File

@ -0,0 +1,161 @@
use std::num::NonZeroU32;
use typst_macros::{Cast, elem, func};
use typst_utils::NonZeroExt;
use crate::diag::SourceResult;
use crate::diag::bail;
use crate::engine::Engine;
use crate::foundations::{Args, Construct, Content, NativeElement, Smart};
use crate::introspection::Locatable;
use crate::model::TableCell;
/// Mark content as a PDF artifact.
/// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`?
#[elem(Locatable)]
pub struct ArtifactElem {
/// The artifact kind.
#[default(ArtifactKind::Other)]
pub kind: ArtifactKind,
/// The content that is an artifact.
#[required]
pub body: Content,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Cast)]
pub enum ArtifactKind {
/// Page header artifacts.
Header,
/// Page footer artifacts.
Footer,
/// Other page artifacts.
Page,
/// Other artifacts.
#[default]
Other,
}
// TODO: feature gate
/// Explicity define this cell as a header cell.
#[func]
pub fn header_cell(
#[named]
#[default(NonZeroU32::ONE)]
level: NonZeroU32,
#[named]
#[default]
scope: TableHeaderScope,
/// The table cell.
cell: TableCell,
) -> Content {
cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope)))
.pack()
}
// TODO: feature gate
/// Explicity define this cell as a data cell.
#[func]
pub fn data_cell(
/// The table cell.
cell: TableCell,
) -> Content {
cell.with_kind(Smart::Custom(TableCellKind::Data)).pack()
}
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
pub enum TableCellKind {
Header(NonZeroU32, TableHeaderScope),
Footer,
#[default]
Data,
}
/// The scope of a table header cell.
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
pub enum TableHeaderScope {
/// The header cell refers to both the row and the column.
Both,
/// The header cell refers to the column.
#[default]
Column,
/// The header cell refers to the row.
Row,
}
impl TableHeaderScope {
pub fn refers_to_column(&self) -> bool {
match self {
TableHeaderScope::Both => true,
TableHeaderScope::Column => true,
TableHeaderScope::Row => false,
}
}
pub fn refers_to_row(&self) -> bool {
match self {
TableHeaderScope::Both => true,
TableHeaderScope::Column => false,
TableHeaderScope::Row => true,
}
}
}
// Used to delimit content for tagged PDF.
#[elem(Locatable, Construct)]
pub struct PdfMarkerTag {
#[internal]
#[required]
pub kind: PdfMarkerTagKind,
#[required]
pub body: Content,
}
impl Construct for PdfMarkerTag {
fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
bail!(args.span, "cannot be constructed manually");
}
}
macro_rules! pdf_marker_tag {
($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ident)+))?,)+) => {
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum PdfMarkerTagKind {
$(
#[doc = $doc]
$variant $(($($ty),+))?
),+
}
impl PdfMarkerTag {
$(
#[doc = $doc]
#[allow(non_snake_case)]
pub fn $variant($($($name: $ty,)+)? body: Content) -> Content {
let span = body.span();
Self {
kind: PdfMarkerTagKind::$variant $(($($name),+))?,
body,
}.pack().spanned(span)
}
)+
}
}
}
pdf_marker_tag! {
/// `TOC`
OutlineBody,
/// `Figure`
FigureBody,
/// `L` bibliography list
Bibliography(numbered: bool),
/// `LBody` wrapping `BibEntry`
BibEntry,
/// `Lbl` (marker) of the list item
ListItemLabel,
/// `LBody` of the enum item
ListItemBody,
/// A generic `Lbl`
Label,
}

View File

@ -1,7 +1,9 @@
//! PDF-specific functionality. //! PDF-specific functionality.
mod accessibility;
mod embed; mod embed;
pub use self::accessibility::*;
pub use self::embed::*; pub use self::embed::*;
use crate::foundations::{Module, Scope}; use crate::foundations::{Module, Scope};
@ -11,5 +13,8 @@ pub fn module() -> Module {
let mut pdf = Scope::deduplicating(); let mut pdf = Scope::deduplicating();
pdf.start_category(crate::Category::Pdf); pdf.start_category(crate::Category::Pdf);
pdf.define_elem::<EmbedElem>(); pdf.define_elem::<EmbedElem>();
pdf.define_elem::<ArtifactElem>();
pdf.define_func::<header_cell>();
pdf.define_func::<data_cell>();
Module::new("pdf", pdf) Module::new("pdf", pdf)
} }

View File

@ -1,4 +1,5 @@
use crate::foundations::{Content, Smart, elem}; use crate::foundations::{Content, Smart, elem};
use crate::introspection::Locatable;
use crate::layout::{Abs, Corners, Length, Rel, Sides}; use crate::layout::{Abs, Corners, Length, Rel, Sides};
use crate::text::{BottomEdge, BottomEdgeMetric, TopEdge, TopEdgeMetric}; use crate::text::{BottomEdge, BottomEdgeMetric, TopEdge, TopEdgeMetric};
use crate::visualize::{Color, FixedStroke, Paint, Stroke}; use crate::visualize::{Color, FixedStroke, Paint, Stroke};
@ -9,7 +10,7 @@ use crate::visualize::{Color, FixedStroke, Paint, Stroke};
/// ```example /// ```example
/// This is #underline[important]. /// This is #underline[important].
/// ``` /// ```
#[elem] #[elem(Locatable)]
pub struct UnderlineElem { pub struct UnderlineElem {
/// How to [stroke] the line. /// How to [stroke] the line.
/// ///
@ -77,7 +78,7 @@ pub struct UnderlineElem {
/// ```example /// ```example
/// #overline[A line over text.] /// #overline[A line over text.]
/// ``` /// ```
#[elem] #[elem(Locatable)]
pub struct OverlineElem { pub struct OverlineElem {
/// How to [stroke] the line. /// How to [stroke] the line.
/// ///
@ -151,7 +152,7 @@ pub struct OverlineElem {
/// ```example /// ```example
/// This is #strike[not] relevant. /// This is #strike[not] relevant.
/// ``` /// ```
#[elem(title = "Strikethrough")] #[elem(title = "Strikethrough", Locatable)]
pub struct StrikeElem { pub struct StrikeElem {
/// How to [stroke] the line. /// How to [stroke] the line.
/// ///
@ -210,7 +211,7 @@ pub struct StrikeElem {
/// ```example /// ```example
/// This is #highlight[important]. /// This is #highlight[important].
/// ``` /// ```
#[elem] #[elem(Locatable)]
pub struct HighlightElem { pub struct HighlightElem {
/// The color to highlight the text with. /// The color to highlight the text with.
/// ///

View File

@ -20,6 +20,7 @@ use crate::foundations::{
Bytes, Content, Derived, OneOrMultiple, Packed, PlainText, ShowSet, Smart, Bytes, Content, Derived, OneOrMultiple, Packed, PlainText, ShowSet, Smart,
StyleChain, Styles, Synthesize, cast, elem, scope, StyleChain, Styles, Synthesize, cast, elem, scope,
}; };
use crate::introspection::Locatable;
use crate::layout::{Em, HAlignment}; use crate::layout::{Em, HAlignment};
use crate::loading::{DataSource, Load}; use crate::loading::{DataSource, Load};
use crate::model::{Figurable, ParElem}; use crate::model::{Figurable, ParElem};
@ -77,6 +78,7 @@ use crate::visualize::Color;
scope, scope,
title = "Raw Text / Code", title = "Raw Text / Code",
Synthesize, Synthesize,
Locatable,
ShowSet, ShowSet,
LocalName, LocalName,
Figurable, Figurable,
@ -612,7 +614,7 @@ fn format_theme_error(error: syntect::LoadingError) -> LoadError {
/// It allows you to access various properties of the line, such as the line /// It allows you to access various properties of the line, such as the line
/// number, the raw non-highlighted text, the highlighted text, and whether it /// number, the raw non-highlighted text, the highlighted text, and whether it
/// is the first or last line of the raw block. /// is the first or last line of the raw block.
#[elem(name = "line", title = "Raw Text / Code Line", PlainText)] #[elem(name = "line", title = "Raw Text / Code Line", Locatable, PlainText)]
pub struct RawLine { pub struct RawLine {
/// The line number of the raw line inside of the raw block, starts at 1. /// The line number of the raw line inside of the raw block, starts at 1.
#[required] #[required]

View File

@ -1,3 +1,4 @@
use crate::introspection::Locatable;
use ttf_parser::Tag; use ttf_parser::Tag;
use crate::foundations::{Content, Smart, elem}; use crate::foundations::{Content, Smart, elem};
@ -12,7 +13,7 @@ use crate::text::{FontMetrics, ScriptMetrics, TextSize};
/// ```example /// ```example
/// Revenue#sub[yearly] /// Revenue#sub[yearly]
/// ``` /// ```
#[elem(title = "Subscript")] #[elem(title = "Subscript", Locatable)]
pub struct SubElem { pub struct SubElem {
/// Whether to create artificial subscripts by lowering and scaling down /// Whether to create artificial subscripts by lowering and scaling down
/// regular glyphs. /// regular glyphs.
@ -67,7 +68,7 @@ pub struct SubElem {
/// ```example /// ```example
/// 1#super[st] try! /// 1#super[st] try!
/// ``` /// ```
#[elem(title = "Superscript")] #[elem(title = "Superscript", Locatable)]
pub struct SuperElem { pub struct SuperElem {
/// Whether to create artificial superscripts by raising and scaling down /// Whether to create artificial superscripts by raising and scaling down
/// regular glyphs. /// regular glyphs.

View File

@ -26,6 +26,7 @@ use crate::foundations::{
Bytes, Cast, Content, Derived, NativeElement, Packed, Smart, StyleChain, cast, elem, Bytes, Cast, Content, Derived, NativeElement, Packed, Smart, StyleChain, cast, elem,
func, scope, func, scope,
}; };
use crate::introspection::Locatable;
use crate::layout::{Length, Rel, Sizing}; use crate::layout::{Length, Rel, Sizing};
use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable}; use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable};
use crate::model::Figurable; use crate::model::Figurable;
@ -50,7 +51,7 @@ use crate::visualize::image::pdf::PdfDocument;
/// ], /// ],
/// ) /// )
/// ``` /// ```
#[elem(scope, LocalName, Figurable)] #[elem(scope, Locatable, LocalName, Figurable)]
pub struct ImageElem { pub struct ImageElem {
/// A [path]($syntax/#paths) to an image file or raw bytes making up an /// A [path]($syntax/#paths) to an image file or raw bytes making up an
/// image in one of the supported [formats]($image.format). /// image in one of the supported [formats]($image.format).
@ -127,7 +128,7 @@ pub struct ImageElem {
/// The height of the image. /// The height of the image.
pub height: Sizing, pub height: Sizing,
/// A text describing the image. /// An alternative description of the image.
pub alt: Option<EcoString>, pub alt: Option<EcoString>,
/// The page number that should be embedded as an image. This attribute only /// The page number that should be embedded as an image. This attribute only

View File

@ -6,3 +6,4 @@ heading = الفصل
outline = المحتويات outline = المحتويات
raw = قائمة raw = قائمة
page = صفحة page = صفحة
# footnote =

View File

@ -6,3 +6,4 @@ heading = Раздел
outline = Съдържание outline = Съдържание
raw = Приложение raw = Приложение
page = стр. page = стр.
# footnote =

View File

@ -6,3 +6,4 @@ heading = Secció
outline = Índex outline = Índex
raw = Llistat raw = Llistat
page = pàgina page = pàgina
# footnote =

View File

@ -6,3 +6,4 @@ heading = Kapitola
outline = Obsah outline = Obsah
raw = Výpis raw = Výpis
page = strana page = strana
# footnote =

View File

@ -6,3 +6,4 @@ heading = Afsnit
outline = Indhold outline = Indhold
raw = Liste raw = Liste
page = side page = side
# footnote =

View File

@ -6,3 +6,4 @@ heading = Abschnitt
outline = Inhaltsverzeichnis outline = Inhaltsverzeichnis
raw = Listing raw = Listing
page = Seite page = Seite
footnote = Fußnote

View File

@ -5,3 +5,4 @@ bibliography = Βιβλιογραφία
heading = Κεφάλαιο heading = Κεφάλαιο
outline = Περιεχόμενα outline = Περιεχόμενα
raw = Παράθεση raw = Παράθεση
# footnote =

View File

@ -6,3 +6,4 @@ heading = Section
outline = Contents outline = Contents
raw = Listing raw = Listing
page = page page = page
footnote = Footnote

View File

@ -6,3 +6,4 @@ heading = Sección
outline = Índice outline = Índice
raw = Listado raw = Listado
page = página page = página
# footnote =

View File

@ -6,3 +6,4 @@ heading = Peatükk
outline = Sisukord outline = Sisukord
raw = List raw = List
page = lk. page = lk.
# footnote =

View File

@ -6,3 +6,4 @@ heading = Atala
outline = Aurkibidea outline = Aurkibidea
raw = Kodea raw = Kodea
page = orria page = orria
# footnote =

View File

@ -6,3 +6,4 @@ heading = Osio
outline = Sisällys outline = Sisällys
raw = Esimerkki raw = Esimerkki
page = sivu page = sivu
# footnote =

View File

@ -6,3 +6,4 @@ heading = Chapitre
outline = Table des matières outline = Table des matières
raw = Liste raw = Liste
page = page page = page
# footnote =

View File

@ -6,3 +6,4 @@ heading = Sección
outline = Índice outline = Índice
raw = Listado raw = Listado
page = páxina page = páxina
# footnote =

View File

@ -6,3 +6,4 @@ heading = חלק
outline = תוכן עניינים outline = תוכן עניינים
raw = קטע מקור raw = קטע מקור
page = עמוד page = עמוד
# footnote =

View File

@ -6,3 +6,4 @@ heading = Odjeljak
outline = Sadržaj outline = Sadržaj
raw = Kôd raw = Kôd
page = str. page = str.
# footnote =

View File

@ -6,3 +6,4 @@ heading = Fejezet
outline = Tartalomjegyzék outline = Tartalomjegyzék
# raw = # raw =
page = oldal page = oldal
# footnote =

View File

@ -6,3 +6,4 @@ heading = Bagian
outline = Daftar Isi outline = Daftar Isi
raw = Kode raw = Kode
page = halaman page = halaman
# footnote =

View File

@ -6,3 +6,4 @@ heading = Kafli
outline = Efnisyfirlit outline = Efnisyfirlit
raw = Sýnishorn raw = Sýnishorn
page = blaðsíða page = blaðsíða
# footnote =

View File

@ -6,3 +6,4 @@ heading = Sezione
outline = Indice outline = Indice
raw = Codice raw = Codice
page = pag. page = pag.
# footnote =

View File

@ -6,3 +6,4 @@ heading = 節
outline = 目次 outline = 目次
raw = リスト raw = リスト
page = ページ page = ページ
# footnote =

View File

@ -6,3 +6,4 @@ heading = Caput
outline = Index capitum outline = Index capitum
raw = Exemplum raw = Exemplum
page = charta page = charta
# footnote =

View File

@ -6,3 +6,4 @@ heading = Sadaļa
outline = Saturs outline = Saturs
raw = Saraksts raw = Saraksts
page = lpp. page = lpp.
# footnote =

View File

@ -6,3 +6,4 @@ heading = Kapittel
outline = Innhold outline = Innhold
raw = Utskrift raw = Utskrift
page = side page = side
# footnote =

View File

@ -6,3 +6,4 @@ heading = Hoofdstuk
outline = Inhoudsopgave outline = Inhoudsopgave
raw = Listing raw = Listing
page = pagina page = pagina
# footnote =

View File

@ -6,3 +6,4 @@ heading = Kapittel
outline = Innhald outline = Innhald
raw = Utskrift raw = Utskrift
page = side page = side
# footnote =

View File

@ -6,3 +6,4 @@ heading = Sekcja
outline = Spis treści outline = Spis treści
raw = Program raw = Program
page = strona page = strona
# footnote =

View File

@ -6,3 +6,4 @@ heading = Secção
outline = Índice outline = Índice
# raw = # raw =
page = página page = página
# footnote =

View File

@ -6,3 +6,4 @@ heading = Seção
outline = Sumário outline = Sumário
raw = Listagem raw = Listagem
page = página page = página
# footnote =

View File

@ -7,3 +7,4 @@ outline = Cuprins
# may be wrong # may be wrong
raw = Listă raw = Listă
page = pagina page = pagina
# footnote =

View File

@ -6,3 +6,4 @@ heading = Раздел
outline = Содержание outline = Содержание
raw = Листинг raw = Листинг
page = с. page = с.
# footnote =

View File

@ -6,3 +6,4 @@ heading = Poglavje
outline = Kazalo outline = Kazalo
raw = Program raw = Program
page = stran page = stran
# footnote =

View File

@ -6,3 +6,4 @@ heading = Kapitull
outline = Përmbajtja outline = Përmbajtja
raw = List raw = List
page = faqe page = faqe
# footnote =

View File

@ -6,3 +6,4 @@ heading = Поглавље
outline = Садржај outline = Садржај
raw = Програм raw = Програм
page = страна page = страна
# footnote =

View File

@ -6,3 +6,4 @@ heading = Avsnitt
outline = Innehåll outline = Innehåll
raw = Kodlistning raw = Kodlistning
page = sida page = sida
# footnote =

View File

@ -6,3 +6,4 @@ heading = Seksyon
outline = Talaan ng mga Nilalaman outline = Talaan ng mga Nilalaman
raw = Listahan raw = Listahan
# page = # page =
# footnote =

View File

@ -6,3 +6,4 @@ heading = Bölüm
outline = İçindekiler outline = İçindekiler
raw = Liste raw = Liste
page = sayfa page = sayfa
# footnote =

View File

@ -6,3 +6,4 @@ heading = Розділ
outline = Зміст outline = Зміст
raw = Лістинг raw = Лістинг
page = c. page = c.
# footnote =

View File

@ -7,3 +7,4 @@ outline = Mục lục
# may be wrong # may be wrong
raw = Chương trình raw = Chương trình
page = trang page = trang
# footnote =

View File

@ -6,3 +6,4 @@ heading = 小節
outline = 目錄 outline = 目錄
raw = 程式 raw = 程式
# page = # page =
# footnote =

View File

@ -6,3 +6,4 @@ heading = 小节
outline = 目录 outline = 目录
raw = 代码 raw = 代码
# page = # page =
# footnote =

View File

@ -19,6 +19,7 @@ typst-macros = { workspace = true }
typst-syntax = { workspace = true } typst-syntax = { workspace = true }
typst-timing = { workspace = true } typst-timing = { workspace = true }
typst-utils = { workspace = true } typst-utils = { workspace = true }
az = { workspace = true }
bytemuck = { workspace = true } bytemuck = { workspace = true }
comemo = { workspace = true } comemo = { workspace = true }
ecow = { workspace = true } ecow = { workspace = true }
@ -27,6 +28,10 @@ infer = { workspace = true }
krilla = { workspace = true } krilla = { workspace = true }
krilla-svg = { workspace = true } krilla-svg = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
smallvec = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View File

@ -2,7 +2,6 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::num::NonZeroU64; use std::num::NonZeroU64;
use ecow::{EcoVec, eco_format}; use ecow::{EcoVec, eco_format};
use krilla::annotation::Annotation;
use krilla::configure::{Configuration, ValidationError, Validator}; use krilla::configure::{Configuration, ValidationError, Validator};
use krilla::destination::{NamedDestination, XyzDestination}; use krilla::destination::{NamedDestination, XyzDestination};
use krilla::embed::EmbedError; use krilla::embed::EmbedError;
@ -11,11 +10,12 @@ use krilla::geom::PathBuilder;
use krilla::page::{PageLabel, PageSettings}; use krilla::page::{PageLabel, PageSettings};
use krilla::pdf::PdfError; use krilla::pdf::PdfError;
use krilla::surface::Surface; use krilla::surface::Surface;
use krilla::tagging::TagId;
use krilla::{Document, SerializeSettings}; use krilla::{Document, SerializeSettings};
use krilla_svg::render_svg_glyph; use krilla_svg::render_svg_glyph;
use typst_library::diag::{SourceDiagnostic, SourceResult, bail, error}; use typst_library::diag::{SourceDiagnostic, SourceResult, bail, error};
use typst_library::foundations::{NativeElement, Repr}; use typst_library::foundations::{NativeElement, Repr};
use typst_library::introspection::Location; use typst_library::introspection::{Location, Tag};
use typst_library::layout::{ use typst_library::layout::{
Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform,
}; };
@ -27,11 +27,12 @@ use typst_syntax::Span;
use crate::PdfOptions; use crate::PdfOptions;
use crate::embed::embed_files; use crate::embed::embed_files;
use crate::image::handle_image; use crate::image::handle_image;
use crate::link::handle_link; use crate::link::{LinkAnnotation, handle_link};
use crate::metadata::build_metadata; use crate::metadata::build_metadata;
use crate::outline::build_outline; use crate::outline::build_outline;
use crate::page::PageLabelExt; use crate::page::PageLabelExt;
use crate::shape::handle_shape; use crate::shape::handle_shape;
use crate::tags::{self, Tags};
use crate::text::handle_text; use crate::text::handle_text;
use crate::util::{AbsExt, TransformExt, convert_path, display_font}; use crate::util::{AbsExt, TransformExt, convert_path, display_font};
@ -47,7 +48,7 @@ pub fn convert(
xmp_metadata: true, xmp_metadata: true,
cmyk_profile: None, cmyk_profile: None,
configuration: options.standards.config, configuration: options.standards.config,
enable_tagging: false, enable_tagging: !options.disable_tags,
render_svg_glyph_fn: render_svg_glyph, render_svg_glyph_fn: render_svg_glyph,
}; };
@ -55,6 +56,7 @@ pub fn convert(
let page_index_converter = PageIndexConverter::new(typst_document, options); let page_index_converter = PageIndexConverter::new(typst_document, options);
let named_destinations = let named_destinations =
collect_named_destinations(typst_document, &page_index_converter); collect_named_destinations(typst_document, &page_index_converter);
let mut gc = GlobalContext::new( let mut gc = GlobalContext::new(
typst_document, typst_document,
options, options,
@ -67,6 +69,7 @@ pub fn convert(
document.set_outline(build_outline(&gc)); document.set_outline(build_outline(&gc));
document.set_metadata(build_metadata(&gc)); document.set_metadata(build_metadata(&gc));
document.set_tag_tree(gc.tags.build_tree());
finish(document, gc, options.standards.config) finish(document, gc, options.standards.config)
} }
@ -106,6 +109,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul
let mut surface = page.surface(); let mut surface = page.surface();
let mut fc = FrameContext::new(typst_page.frame.size()); let mut fc = FrameContext::new(typst_page.frame.size());
tags::page_start(gc, &mut surface);
handle_frame( handle_frame(
&mut fc, &mut fc,
&typst_page.frame, &typst_page.frame,
@ -114,11 +119,11 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul
gc, gc,
)?; )?;
tags::page_end(gc, &mut surface);
surface.finish(); surface.finish();
for annotation in fc.annotations { tags::add_annotations(gc, &mut page, fc.link_annotations);
page.add_annotation(annotation);
}
} }
} }
@ -172,14 +177,14 @@ impl State {
/// Context needed for converting a single frame. /// Context needed for converting a single frame.
pub(crate) struct FrameContext { pub(crate) struct FrameContext {
states: Vec<State>, states: Vec<State>,
annotations: Vec<Annotation>, link_annotations: Vec<LinkAnnotation>,
} }
impl FrameContext { impl FrameContext {
pub(crate) fn new(size: Size) -> Self { pub(crate) fn new(size: Size) -> Self {
Self { Self {
states: vec![State::new(size)], states: vec![State::new(size)],
annotations: vec![], link_annotations: Vec::new(),
} }
} }
@ -199,8 +204,18 @@ impl FrameContext {
self.states.last_mut().unwrap() self.states.last_mut().unwrap()
} }
pub(crate) fn push_annotation(&mut self, annotation: Annotation) { pub(crate) fn get_link_annotation(
self.annotations.push(annotation); &mut self,
link_id: tags::LinkId,
) -> Option<&mut LinkAnnotation> {
self.link_annotations
.iter_mut()
.rev()
.find(|annot| annot.id == link_id)
}
pub(crate) fn push_link_annotation(&mut self, annotation: LinkAnnotation) {
self.link_annotations.push(annotation);
} }
} }
@ -226,6 +241,8 @@ pub(crate) struct GlobalContext<'a> {
/// The languages used throughout the document. /// The languages used throughout the document.
pub(crate) languages: BTreeMap<Lang, usize>, pub(crate) languages: BTreeMap<Lang, usize>,
pub(crate) page_index_converter: PageIndexConverter, pub(crate) page_index_converter: PageIndexConverter,
/// Tagged PDF context.
pub(crate) tags: Tags,
} }
impl<'a> GlobalContext<'a> { impl<'a> GlobalContext<'a> {
@ -245,6 +262,8 @@ impl<'a> GlobalContext<'a> {
image_spans: HashSet::new(), image_spans: HashSet::new(),
languages: BTreeMap::new(), languages: BTreeMap::new(),
page_index_converter, page_index_converter,
tags: Tags::new(),
} }
} }
} }
@ -279,8 +298,9 @@ pub(crate) fn handle_frame(
FrameItem::Image(image, size, span) => { FrameItem::Image(image, size, span) => {
handle_image(gc, fc, image, *size, surface, *span)? handle_image(gc, fc, image, *size, surface, *span)?
} }
FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size),
FrameItem::Tag(_) => {} FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem)?,
FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc),
} }
fc.pop(); fc.pop();
@ -295,7 +315,7 @@ pub(crate) fn handle_group(
fc: &mut FrameContext, fc: &mut FrameContext,
group: &GroupItem, group: &GroupItem,
surface: &mut Surface, surface: &mut Surface,
context: &mut GlobalContext, gc: &mut GlobalContext,
) -> SourceResult<()> { ) -> SourceResult<()> {
fc.push(); fc.push();
fc.state_mut().pre_concat(group.transform); fc.state_mut().pre_concat(group.transform);
@ -311,10 +331,12 @@ pub(crate) fn handle_group(
.and_then(|p| p.transform(fc.state().transform.to_krilla())); .and_then(|p| p.transform(fc.state().transform.to_krilla()));
if let Some(clip_path) = &clip_path { if let Some(clip_path) = &clip_path {
let mut handle = tags::start_marked(gc, surface);
let surface = handle.surface();
surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero); surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero);
} }
handle_frame(fc, &group.frame, None, surface, context)?; handle_frame(fc, &group.frame, None, surface, gc)?;
if clip_path.is_some() { if clip_path.is_some() {
surface.pop(); surface.pop();
@ -353,6 +375,22 @@ fn finish(
.collect::<EcoVec<_>>(); .collect::<EcoVec<_>>();
Err(errors) Err(errors)
} }
KrillaError::DuplicateTagId(id, loc) => {
let span = to_span(loc);
let id = display_tag_id(&id);
bail!(
span, "duplicate tag id `{id}`";
hint: "please report this as a bug"
)
}
KrillaError::UnknownTagId(id, loc) => {
let span = to_span(loc);
let id = display_tag_id(&id);
bail!(
span, "unknown tag id `{id}`";
hint: "please report this as a bug"
)
}
KrillaError::Image(_, loc) => { KrillaError::Image(_, loc) => {
let span = to_span(loc); let span = to_span(loc);
bail!(span, "failed to process image"); bail!(span, "failed to process image");
@ -386,24 +424,24 @@ fn finish(
} }
} }
} }
KrillaError::DuplicateTagId(_, loc) => {
let span = to_span(loc);
bail!(span,
"duplicate tag id";
hint: "please report this as a bug"
);
}
KrillaError::UnknownTagId(_, loc) => {
let span = to_span(loc);
bail!(span,
"unknown tag id";
hint: "please report this as a bug"
);
}
}, },
} }
} }
fn display_tag_id(id: &TagId) -> impl std::fmt::Display + use<'_> {
typst_utils::display(|f| {
if let Ok(str) = std::str::from_utf8(id.as_bytes()) {
f.write_str(str)
} else {
f.write_str("0x")?;
for b in id.as_bytes() {
write!(f, "{b:x}")?;
}
Ok(())
}
})
}
/// Converts a krilla error into a Typst error. /// Converts a krilla error into a Typst error.
fn convert_error( fn convert_error(
gc: &GlobalContext, gc: &GlobalContext,
@ -572,16 +610,20 @@ fn convert_error(
} }
// The below errors cannot occur yet, only once Typst supports full PDF/A // 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. // and PDF/UA. But let's still add a message just to be on the safe side.
ValidationError::MissingAnnotationAltText(_) => error!( ValidationError::MissingAnnotationAltText(loc) => {
Span::detached(), let span = to_span(*loc);
"{prefix} missing annotation alt text"; error!(
span, "{prefix} missing annotation alt text";
hint: "please report this as a bug" hint: "please report this as a bug"
), )
ValidationError::MissingAltText(_) => error!( }
Span::detached(), ValidationError::MissingAltText(loc) => {
"{prefix} missing alt text"; let span = to_span(*loc);
error!(
span, "{prefix} missing alt text";
hint: "make sure your images and equations have alt text" hint: "make sure your images and equations have alt text"
), )
}
ValidationError::NoDocumentLanguage => error!( ValidationError::NoDocumentLanguage => error!(
Span::detached(), Span::detached(),
"{prefix} missing document language"; "{prefix} missing document language";

View File

@ -5,6 +5,7 @@ use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba};
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
use krilla::pdf::PdfDocument; use krilla::pdf::PdfDocument;
use krilla::surface::Surface; use krilla::surface::Surface;
use krilla::tagging::SpanTag;
use krilla_svg::{SurfaceExt, SvgSettings}; use krilla_svg::{SurfaceExt, SvgSettings};
use typst_library::diag::{SourceResult, bail}; use typst_library::diag::{SourceResult, bail};
use typst_library::foundations::Smart; use typst_library::foundations::Smart;
@ -15,6 +16,7 @@ use typst_library::visualize::{
use typst_syntax::Span; use typst_syntax::Span;
use crate::convert::{FrameContext, GlobalContext}; use crate::convert::{FrameContext, GlobalContext};
use crate::tags;
use crate::util::{SizeExt, TransformExt}; use crate::util::{SizeExt, TransformExt};
#[typst_macros::time(name = "handle image")] #[typst_macros::time(name = "handle image")]
@ -31,12 +33,11 @@ pub(crate) fn handle_image(
let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth); let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth);
if let Some(alt) = image.alt() {
surface.start_alt_text(alt);
}
gc.image_spans.insert(span); gc.image_spans.insert(span);
let mut handle =
tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt()));
let surface = handle.surface();
match image.kind() { match image.kind() {
ImageKind::Raster(raster) => { ImageKind::Raster(raster) => {
let (exif_transform, new_size) = exif_transform(raster, size); let (exif_transform, new_size) = exif_transform(raster, size);
@ -66,10 +67,6 @@ pub(crate) fn handle_image(
} }
} }
if image.alt().is_some() {
surface.end_alt_text();
}
surface.pop(); surface.pop();
surface.reset_location(); surface.reset_location();

View File

@ -9,6 +9,7 @@ mod outline;
mod page; mod page;
mod paint; mod paint;
mod shape; mod shape;
mod tags;
mod text; mod text;
mod util; mod util;
@ -53,6 +54,11 @@ pub struct PdfOptions<'a> {
pub page_ranges: Option<PageRanges>, pub page_ranges: Option<PageRanges>,
/// A list of PDF standards that Typst will enforce conformance with. /// A list of PDF standards that Typst will enforce conformance with.
pub standards: PdfStandards, pub standards: PdfStandards,
/// By default, even when not producing a `PDF/UA-1` document, a tagged PDF
/// document is written to provide a baseline of accessibility. In some
/// circumstances, for example when trying to reduce the size of a document,
/// it can be desirable to disable tagged PDF.
pub disable_tags: bool,
} }
/// Encapsulates a list of compatible PDF standards. /// Encapsulates a list of compatible PDF standards.
@ -104,6 +110,7 @@ impl PdfStandards {
PdfStandard::A_4 => set_validator(Validator::A4)?, PdfStandard::A_4 => set_validator(Validator::A4)?,
PdfStandard::A_4f => set_validator(Validator::A4F)?, PdfStandard::A_4f => set_validator(Validator::A4F)?,
PdfStandard::A_4e => set_validator(Validator::A4E)?, PdfStandard::A_4e => set_validator(Validator::A4E)?,
PdfStandard::Ua_1 => set_validator(Validator::UA1)?,
} }
} }
@ -187,4 +194,7 @@ pub enum PdfStandard {
/// PDF/A-4e. /// PDF/A-4e.
#[serde(rename = "a-4e")] #[serde(rename = "a-4e")]
A_4e, A_4e,
/// PDF/UA-1.
#[serde(rename = "ua-1")]
Ua_1,
} }

View File

@ -1,91 +1,103 @@
use ecow::EcoString;
use krilla::action::{Action, LinkAction}; use krilla::action::{Action, LinkAction};
use krilla::annotation::{LinkAnnotation, Target}; use krilla::annotation::Target;
use krilla::configure::Validator;
use krilla::destination::XyzDestination; use krilla::destination::XyzDestination;
use krilla::geom::Rect; use krilla::geom as kg;
use typst_library::layout::{Abs, Point, Size}; use typst_library::layout::{Point, Position, Size};
use typst_library::model::Destination; use typst_library::model::Destination;
use typst_syntax::Span;
use crate::convert::{FrameContext, GlobalContext}; use crate::convert::{FrameContext, GlobalContext};
use crate::tags::{self, Placeholder, TagNode};
use crate::util::{AbsExt, PointExt}; use crate::util::{AbsExt, PointExt};
pub(crate) struct LinkAnnotation {
pub(crate) id: tags::LinkId,
pub(crate) placeholder: Placeholder,
pub(crate) alt: Option<String>,
pub(crate) quad_points: Vec<kg::Quadrilateral>,
pub(crate) target: Target,
pub(crate) span: Span,
}
pub(crate) fn handle_link( pub(crate) fn handle_link(
fc: &mut FrameContext, fc: &mut FrameContext,
gc: &mut GlobalContext, gc: &mut GlobalContext,
dest: &Destination, dest: &Destination,
size: Size, size: Size,
) { ) {
let mut min_x = Abs::inf(); let target = match dest {
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) => { Destination::Url(u) => {
fc.push_annotation( Target::Action(Action::Link(LinkAction::new(u.to_string())))
LinkAnnotation::new(
rect,
Target::Action(Action::Link(LinkAction::new(u.to_string()))),
)
.into(),
);
return;
} }
Destination::Position(p) => *p, Destination::Position(p) => match pos_to_target(gc, *p) {
Some(target) => target,
None => return,
},
Destination::Location(loc) => { Destination::Location(loc) => {
if let Some(nd) = gc.loc_to_names.get(loc) { if let Some(nd) = gc.loc_to_names.get(loc) {
// If a named destination has been registered, it's already guaranteed to // If a named destination has been registered, it's already guaranteed to
// not point to an excluded page. // not point to an excluded page.
fc.push_annotation( Target::Destination(krilla::destination::Destination::Named(nd.clone()))
LinkAnnotation::new(
rect,
Target::Destination(krilla::destination::Destination::Named(
nd.clone(),
)),
)
.into(),
);
return;
} else { } else {
gc.document.introspector.position(*loc) let pos = gc.document.introspector.position(*loc);
match pos_to_target(gc, pos) {
Some(target) => target,
None => return,
}
} }
} }
}; };
let page_index = pos.page.get() - 1; let Some((link_id, link, link_nodes)) = gc.tags.stack.find_parent_link() else {
if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { unreachable!("expected a link parent")
fc.push_annotation( };
LinkAnnotation::new( let alt = link.alt.as_ref().map(EcoString::to_string);
rect,
Target::Destination(krilla::destination::Destination::Xyz( let quad = to_quadrilateral(fc, size);
XyzDestination::new(index, pos.point.to_krilla()),
)), // Unfortunately quadpoints still aren't well supported by most PDF readers,
) // even by acrobat. Which is understandable since they were only introduced
.into(), // in PDF 1.6 (2005) /s
); let should_use_quadpoints = gc.options.standards.config.validator() == Validator::UA1;
match fc.get_link_annotation(link_id) {
Some(annotation) if should_use_quadpoints => annotation.quad_points.push(quad),
_ => {
let placeholder = gc.tags.placeholders.reserve();
link_nodes.push(TagNode::Placeholder(placeholder));
fc.push_link_annotation(LinkAnnotation {
id: link_id,
placeholder,
quad_points: vec![quad],
alt,
target,
span: link.span,
});
}
} }
} }
/// Compute the quadrilateral representing the transformed rectangle of this frame.
fn to_quadrilateral(fc: &FrameContext, size: Size) -> kg::Quadrilateral {
let pos = Point::zero();
let points = [
pos + Point::with_y(size.y),
pos + size.to_point(),
pos + Point::with_x(size.x),
pos,
];
kg::Quadrilateral(points.map(|point| {
let p = point.transform(fc.state().transform());
kg::Point::from_xy(p.x.to_f32(), p.y.to_f32())
}))
}
fn pos_to_target(gc: &mut GlobalContext, pos: Position) -> Option<Target> {
let page_index = pos.page.get() - 1;
let index = gc.page_index_converter.pdf_page_index(page_index)?;
let dest = XyzDestination::new(index, pos.point.to_krilla());
Some(Target::Destination(krilla::destination::Destination::Xyz(dest)))
}

View File

@ -1,12 +1,13 @@
use krilla::geom::{Path, PathBuilder, Rect}; use krilla::geom::{Path, PathBuilder, Rect};
use krilla::surface::Surface; use krilla::surface::Surface;
use typst_library::diag::SourceResult; use typst_library::diag::SourceResult;
use typst_library::pdf::ArtifactKind;
use typst_library::visualize::{Geometry, Shape}; use typst_library::visualize::{Geometry, Shape};
use typst_syntax::Span; use typst_syntax::Span;
use crate::convert::{FrameContext, GlobalContext}; use crate::convert::{FrameContext, GlobalContext};
use crate::paint;
use crate::util::{AbsExt, TransformExt, convert_path}; use crate::util::{AbsExt, TransformExt, convert_path};
use crate::{paint, tags};
#[typst_macros::time(name = "handle shape")] #[typst_macros::time(name = "handle shape")]
pub(crate) fn handle_shape( pub(crate) fn handle_shape(
@ -16,6 +17,9 @@ pub(crate) fn handle_shape(
gc: &mut GlobalContext, gc: &mut GlobalContext,
span: Span, span: Span,
) -> SourceResult<()> { ) -> SourceResult<()> {
let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other);
let surface = handle.surface();
surface.set_location(span.into_raw().get()); surface.set_location(span.into_raw().get());
surface.push_transform(&fc.state().transform().to_krilla()); surface.push_transform(&fc.state().transform().to_krilla());

View File

@ -0,0 +1,101 @@
use krilla::tagging::{ListNumbering, TagKind};
use crate::tags::TagNode;
#[derive(Debug)]
pub(crate) struct ListCtx {
numbering: ListNumbering,
items: Vec<ListItem>,
}
#[derive(Debug)]
struct ListItem {
label: Vec<TagNode>,
body: Option<Vec<TagNode>>,
sub_list: Option<TagNode>,
}
impl ListCtx {
pub(crate) fn new(numbering: ListNumbering) -> Self {
Self { numbering, items: Vec::new() }
}
pub(crate) fn push_label(&mut self, nodes: Vec<TagNode>) {
self.items.push(ListItem { label: nodes, body: None, sub_list: None });
}
pub(crate) fn push_body(&mut self, mut nodes: Vec<TagNode>) {
let item = self.items.last_mut().expect("ListItemLabel");
// Nested lists are expected to have the following structure:
//
// Typst code
// ```
// - a
// - b
// - c
// - d
// - e
// ```
//
// Structure tree
// ```
// <L>
// <LI>
// <Lbl> `-`
// <LBody> `a`
// <LI>
// <Lbl> `-`
// <LBody> `b`
// <L>
// <LI>
// <Lbl> `-`
// <LBody> `c`
// <LI>
// <Lbl> `-`
// <LBody> `d`
// <LI>
// <Lbl> `-`
// <LBody> `d`
// ```
//
// So move the nested list out of the list item.
if let [_, TagNode::Group(tag, _)] = nodes.as_slice() {
if matches!(tag.kind, TagKind::L(_)) {
item.sub_list = nodes.pop();
}
}
item.body = Some(nodes);
}
pub(crate) fn push_bib_entry(&mut self, nodes: Vec<TagNode>) {
let nodes = vec![TagNode::Group(TagKind::BibEntry.into(), nodes)];
// Bibliography lists cannot be nested, but may be missing labels.
if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) {
item.body = Some(nodes);
} else {
self.items.push(ListItem {
label: Vec::new(),
body: Some(nodes),
sub_list: None,
});
}
}
pub(crate) fn build_list(self, mut nodes: Vec<TagNode>) -> TagNode {
for item in self.items.into_iter() {
nodes.push(TagNode::Group(
TagKind::LI.into(),
vec![
TagNode::Group(TagKind::Lbl.into(), item.label),
TagNode::Group(TagKind::LBody.into(), item.body.unwrap_or_default()),
],
));
if let Some(sub_list) = item.sub_list {
nodes.push(sub_list);
}
}
TagNode::Group(TagKind::L(self.numbering).into(), nodes)
}
}

View File

@ -0,0 +1,666 @@
use std::cell::OnceCell;
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::ops::{Deref, DerefMut};
use ecow::EcoString;
use krilla::configure::Validator;
use krilla::page::Page;
use krilla::surface::Surface;
use krilla::tagging::{
ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, TableDataCell,
Tag, TagBuilder, TagGroup, TagKind, TagTree,
};
use typst_library::diag::SourceResult;
use typst_library::foundations::{
Content, LinkMarker, NativeElement, Packed, RefableProperty, Settable,
SettableProperty, StyleChain,
};
use typst_library::introspection::Location;
use typst_library::layout::RepeatElem;
use typst_library::math::EquationElem;
use typst_library::model::{
Destination, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry,
HeadingElem, ListElem, Outlinable, OutlineEntry, QuoteElem, TableCell, TableElem,
TermsElem,
};
use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind};
use typst_library::visualize::ImageElem;
use crate::convert::GlobalContext;
use crate::link::LinkAnnotation;
use crate::tags::list::ListCtx;
use crate::tags::outline::OutlineCtx;
use crate::tags::table::TableCtx;
mod list;
mod outline;
mod table;
pub(crate) fn handle_start(
gc: &mut GlobalContext,
surface: &mut Surface,
elem: &Content,
) -> SourceResult<()> {
if gc.tags.in_artifact.is_some() {
// Don't nest artifacts
return Ok(());
}
let loc = elem.location().expect("elem to be locatable");
if let Some(artifact) = elem.to_packed::<ArtifactElem>() {
let kind = artifact.kind.get(StyleChain::default());
push_artifact(gc, surface, loc, kind);
return Ok(());
} else if let Some(_) = elem.to_packed::<RepeatElem>() {
push_artifact(gc, surface, loc, ArtifactKind::Other);
return Ok(());
}
let tag: Tag = if let Some(tag) = elem.to_packed::<PdfMarkerTag>() {
match tag.kind {
PdfMarkerTagKind::OutlineBody => {
push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new()))?;
return Ok(());
}
PdfMarkerTagKind::FigureBody => TagKind::Figure.into(),
PdfMarkerTagKind::Bibliography(numbered) => {
let numbering =
if numbered { ListNumbering::Decimal } else { ListNumbering::None };
push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?;
return Ok(());
}
PdfMarkerTagKind::BibEntry => {
push_stack(gc, loc, StackEntryKind::BibEntry)?;
return Ok(());
}
PdfMarkerTagKind::ListItemLabel => {
push_stack(gc, loc, StackEntryKind::ListItemLabel)?;
return Ok(());
}
PdfMarkerTagKind::ListItemBody => {
push_stack(gc, loc, StackEntryKind::ListItemBody)?;
return Ok(());
}
PdfMarkerTagKind::Label => TagKind::Lbl.into(),
}
} else if let Some(entry) = elem.to_packed::<OutlineEntry>() {
push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone()))?;
return Ok(());
} else if let Some(_list) = elem.to_packed::<ListElem>() {
let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker`
push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?;
return Ok(());
} else if let Some(_enumeration) = elem.to_packed::<EnumElem>() {
let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering`
push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?;
return Ok(());
} else if let Some(_enumeration) = elem.to_packed::<TermsElem>() {
let numbering = ListNumbering::None;
push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?;
return Ok(());
} else if let Some(_) = elem.to_packed::<FigureElem>() {
// Wrap the figure tag and the sibling caption in a container, if the
// caption is contained within the figure like recommended for tables
// screen readers might ignore it.
// TODO: maybe this could be a `NonStruct` tag?
TagKind::P.into()
} else if let Some(_) = elem.to_packed::<FigureCaption>() {
TagKind::Caption.into()
} else if let Some(image) = elem.to_packed::<ImageElem>() {
let alt = image.alt.get_as_ref().map(|s| s.to_string());
let figure_tag = (gc.tags.stack.parent())
.and_then(StackEntryKind::as_standard_mut)
.filter(|tag| tag.kind == TagKind::Figure);
if let Some(figure_tag) = figure_tag {
// Set alt text of outer figure tag, if not present.
if figure_tag.alt_text.is_none() {
figure_tag.alt_text = alt;
}
return Ok(());
} else {
TagKind::Figure.with_alt_text(alt)
}
} else if let Some(equation) = elem.to_packed::<EquationElem>() {
let alt = equation.alt.get_as_ref().map(|s| s.to_string());
TagKind::Formula.with_alt_text(alt)
} else if let Some(table) = elem.to_packed::<TableElem>() {
let table_id = gc.tags.next_table_id();
let summary = table.summary.get_as_ref().map(|s| s.to_string());
let ctx = TableCtx::new(table_id, summary);
push_stack(gc, loc, StackEntryKind::Table(ctx))?;
return Ok(());
} else if let Some(cell) = elem.to_packed::<TableCell>() {
let table_ctx = gc.tags.stack.parent_table();
// Only repeated table headers and footer cells are layed out multiple
// times. Mark duplicate headers as artifacts, since they have no
// semantic meaning in the tag tree, which doesn't use page breaks for
// it's semantic structure.
if table_ctx.is_some_and(|ctx| ctx.contains(cell)) {
// TODO: currently the first layouted cell is picked to be part of
// the tag tree, for repeating footers this will be the cell on the
// first page. Maybe it should be the cell on the last page, but that
// would require more changes in the layouting code, or a pre-pass
// on the frames to figure out if there are other footers following.
push_artifact(gc, surface, loc, ArtifactKind::Other);
} else {
push_stack(gc, loc, StackEntryKind::TableCell(cell.clone()))?;
}
return Ok(());
} else if let Some(heading) = elem.to_packed::<HeadingElem>() {
let level = heading.level().try_into().unwrap_or(NonZeroU32::MAX);
let name = heading.body.plain_text().to_string();
TagKind::Hn(level, Some(name)).into()
} else if let Some(link) = elem.to_packed::<LinkMarker>() {
let link_id = gc.tags.next_link_id();
push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone()))?;
return Ok(());
} else if let Some(_) = elem.to_packed::<FootnoteElem>() {
push_stack(gc, loc, StackEntryKind::FootNoteRef)?;
return Ok(());
} else if let Some(entry) = elem.to_packed::<FootnoteEntry>() {
let footnote_loc = entry.note.location().unwrap();
push_stack(gc, loc, StackEntryKind::FootNoteEntry(footnote_loc))?;
return Ok(());
} else if let Some(quote) = elem.to_packed::<QuoteElem>() {
// TODO: should the attribution be handled somehow?
if quote.block.get(StyleChain::default()) {
TagKind::BlockQuote.into()
} else {
TagKind::InlineQuote.into()
}
} else {
return Ok(());
};
let tag = tag.with_location(Some(elem.span().into_raw().get()));
push_stack(gc, loc, StackEntryKind::Standard(tag))?;
Ok(())
}
pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) {
if let Some((l, _)) = gc.tags.in_artifact {
if l == loc {
pop_artifact(gc, surface);
}
return;
}
let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else {
return;
};
let node = match entry.kind {
StackEntryKind::Standard(tag) => TagNode::Group(tag, entry.nodes),
StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes),
StackEntryKind::OutlineEntry(outline_entry) => {
let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline()
else {
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
let tag = TagKind::TOCI
.with_location(Some(outline_entry.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
return;
};
outline_ctx.insert(outline_nodes, outline_entry, entry.nodes);
return;
}
StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes),
StackEntryKind::TableCell(cell) => {
let Some(table_ctx) = gc.tags.stack.parent_table() else {
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
let tag = TagKind::TD(TableDataCell::new())
.with_location(Some(cell.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
return;
};
table_ctx.insert(&cell, entry.nodes);
return;
}
StackEntryKind::List(list) => list.build_list(entry.nodes),
StackEntryKind::ListItemLabel => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_label(entry.nodes);
return;
}
StackEntryKind::ListItemBody => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_body(entry.nodes);
return;
}
StackEntryKind::BibEntry => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_bib_entry(entry.nodes);
return;
}
StackEntryKind::Link(_, link) => {
let alt = link.alt.as_ref().map(EcoString::to_string);
let tag = TagKind::Link.with_alt_text(alt);
let mut node = TagNode::Group(tag, entry.nodes);
// Wrap link in reference tag, if it's not a url.
if let Destination::Position(_) | Destination::Location(_) = link.dest {
node = TagNode::Group(TagKind::Reference.into(), vec![node]);
}
node
}
StackEntryKind::FootNoteRef => {
// transparently inset all children.
gc.tags.extend(entry.nodes);
gc.tags.push(TagNode::FootnoteEntry(loc));
return;
}
StackEntryKind::FootNoteEntry(footnote_loc) => {
// Store footnotes separately so they can be inserted directly after
// the footnote reference in the reading order.
let tag = TagNode::Group(TagKind::Note.into(), entry.nodes);
gc.tags.footnotes.insert(footnote_loc, tag);
return;
}
};
gc.tags.push(node);
}
fn push_stack(
gc: &mut GlobalContext,
loc: Location,
kind: StackEntryKind,
) -> SourceResult<()> {
if !gc.tags.context_supports(&kind) {
if gc.options.standards.config.validator() == Validator::UA1 {
// TODO: error
} else {
// TODO: warning
}
}
gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() });
Ok(())
}
fn push_artifact(
gc: &mut GlobalContext,
surface: &mut Surface,
loc: Location,
kind: ArtifactKind,
) {
let ty = artifact_type(kind);
let id = surface.start_tagged(ContentTag::Artifact(ty));
gc.tags.push(TagNode::Leaf(id));
gc.tags.in_artifact = Some((loc, kind));
}
fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) {
surface.end_tagged();
gc.tags.in_artifact = None;
}
pub(crate) fn page_start(gc: &mut GlobalContext, surface: &mut Surface) {
if let Some((_, kind)) = gc.tags.in_artifact {
let ty = artifact_type(kind);
let id = surface.start_tagged(ContentTag::Artifact(ty));
gc.tags.push(TagNode::Leaf(id));
}
}
pub(crate) fn page_end(gc: &mut GlobalContext, surface: &mut Surface) {
if gc.tags.in_artifact.is_some() {
surface.end_tagged();
}
}
/// Add all annotations that were found in the page frame.
pub(crate) fn add_annotations(
gc: &mut GlobalContext,
page: &mut Page,
annotations: Vec<LinkAnnotation>,
) {
for annotation in annotations.into_iter() {
let LinkAnnotation { id: _, placeholder, alt, quad_points, target, span } =
annotation;
let annot = krilla::annotation::Annotation::new_link(
krilla::annotation::LinkAnnotation::new_with_quad_points(quad_points, target),
alt,
)
.with_location(Some(span.into_raw().get()));
let annot_id = page.add_tagged_annotation(annot);
gc.tags.placeholders.init(placeholder, Node::Leaf(annot_id));
}
}
pub(crate) struct Tags {
/// The intermediary stack of nested tag groups.
pub(crate) stack: TagStack,
/// A list of placeholders corresponding to a [`TagNode::Placeholder`].
pub(crate) placeholders: Placeholders,
/// Footnotes are inserted directly after the footenote reference in the
/// reading order. Because of some layouting bugs, the entry might appear
/// before the reference in the text, so we only resolve them once tags
/// for the whole document are generated.
pub(crate) footnotes: HashMap<Location, TagNode>,
pub(crate) in_artifact: Option<(Location, ArtifactKind)>,
/// Used to group multiple link annotations using quad points.
pub(crate) link_id: LinkId,
/// Used to generate IDs referenced in table `Headers` attributes.
/// The IDs must be document wide unique.
pub(crate) table_id: TableId,
/// The output.
pub(crate) tree: Vec<TagNode>,
}
impl Tags {
pub(crate) fn new() -> Self {
Self {
stack: TagStack(Vec::new()),
placeholders: Placeholders(Vec::new()),
footnotes: HashMap::new(),
in_artifact: None,
link_id: LinkId(0),
table_id: TableId(0),
tree: Vec::new(),
}
}
pub(crate) fn push(&mut self, node: TagNode) {
if let Some(entry) = self.stack.last_mut() {
entry.nodes.push(node);
} else {
self.tree.push(node);
}
}
pub(crate) fn extend(&mut self, nodes: impl IntoIterator<Item = TagNode>) {
if let Some(entry) = self.stack.last_mut() {
entry.nodes.extend(nodes);
} else {
self.tree.extend(nodes);
}
}
pub(crate) fn build_tree(&mut self) -> TagTree {
let children = std::mem::take(&mut self.tree)
.into_iter()
.map(|node| self.resolve_node(node))
.collect::<Vec<_>>();
TagTree::from(children)
}
/// Resolves [`Placeholder`] nodes.
fn resolve_node(&mut self, node: TagNode) -> Node {
match node {
TagNode::Group(tag, nodes) => {
let children = nodes
.into_iter()
.map(|node| self.resolve_node(node))
.collect::<Vec<_>>();
Node::Group(TagGroup::with_children(tag, children))
}
TagNode::Leaf(identifier) => Node::Leaf(identifier),
TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder),
TagNode::FootnoteEntry(loc) => {
let node = self.footnotes.remove(&loc).expect("footnote");
self.resolve_node(node)
}
}
}
fn context_supports(&self, _tag: &StackEntryKind) -> bool {
// TODO: generate using: https://pdfa.org/resource/iso-ts-32005-hierarchical-inclusion-rules/
true
}
fn next_link_id(&mut self) -> LinkId {
self.link_id.0 += 1;
self.link_id
}
fn next_table_id(&mut self) -> TableId {
self.table_id.0 += 1;
self.table_id
}
}
pub(crate) struct TagStack(Vec<StackEntry>);
impl Deref for TagStack {
type Target = Vec<StackEntry>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for TagStack {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl TagStack {
pub(crate) fn parent(&mut self) -> Option<&mut StackEntryKind> {
self.0.last_mut().map(|e| &mut e.kind)
}
pub(crate) fn parent_table(&mut self) -> Option<&mut TableCtx> {
self.parent()?.as_table_mut()
}
pub(crate) fn parent_list(&mut self) -> Option<&mut ListCtx> {
self.parent()?.as_list_mut()
}
pub(crate) fn parent_outline(
&mut self,
) -> Option<(&mut OutlineCtx, &mut Vec<TagNode>)> {
self.0.last_mut().and_then(|e| {
let ctx = e.kind.as_outline_mut()?;
Some((ctx, &mut e.nodes))
})
}
pub(crate) fn find_parent_link(
&mut self,
) -> Option<(LinkId, &LinkMarker, &mut Vec<TagNode>)> {
self.0.iter_mut().rev().find_map(|e| {
let (link_id, link) = e.kind.as_link()?;
Some((link_id, link.as_ref(), &mut e.nodes))
})
}
}
pub(crate) struct Placeholders(Vec<OnceCell<Node>>);
impl Placeholders {
pub(crate) fn reserve(&mut self) -> Placeholder {
let idx = self.0.len();
self.0.push(OnceCell::new());
Placeholder(idx)
}
pub(crate) fn init(&mut self, placeholder: Placeholder, node: Node) {
self.0[placeholder.0]
.set(node)
.map_err(|_| ())
.expect("placeholder to be uninitialized");
}
pub(crate) fn take(&mut self, placeholder: Placeholder) -> Node {
self.0[placeholder.0].take().expect("initialized placeholder node")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct TableId(u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct LinkId(u32);
#[derive(Debug)]
pub(crate) struct StackEntry {
pub(crate) loc: Location,
pub(crate) kind: StackEntryKind,
pub(crate) nodes: Vec<TagNode>,
}
#[derive(Debug)]
pub(crate) enum StackEntryKind {
Standard(Tag),
Outline(OutlineCtx),
OutlineEntry(Packed<OutlineEntry>),
Table(TableCtx),
TableCell(Packed<TableCell>),
List(ListCtx),
ListItemLabel,
ListItemBody,
BibEntry,
Link(LinkId, Packed<LinkMarker>),
/// The footnote reference in the text.
FootNoteRef,
/// The footnote entry at the end of the page. Contains the [`Location`] of
/// the [`FootnoteElem`](typst_library::model::FootnoteElem).
FootNoteEntry(Location),
}
impl StackEntryKind {
pub(crate) fn as_standard_mut(&mut self) -> Option<&mut Tag> {
if let Self::Standard(v) = self { Some(v) } else { None }
}
pub(crate) fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> {
if let Self::Outline(v) = self { Some(v) } else { None }
}
pub(crate) fn as_table_mut(&mut self) -> Option<&mut TableCtx> {
if let Self::Table(v) = self { Some(v) } else { None }
}
pub(crate) fn as_list_mut(&mut self) -> Option<&mut ListCtx> {
if let Self::List(v) = self { Some(v) } else { None }
}
pub(crate) fn as_link(&self) -> Option<(LinkId, &Packed<LinkMarker>)> {
if let Self::Link(id, link) = self { Some((*id, link)) } else { None }
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum TagNode {
Group(Tag, Vec<TagNode>),
Leaf(Identifier),
/// Allows inserting a placeholder into the tag tree.
/// Currently used for [`krilla::page::Page::add_tagged_annotation`].
Placeholder(Placeholder),
FootnoteEntry(Location),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct Placeholder(usize);
/// Automatically calls [`Surface::end_tagged`] when dropped.
pub(crate) struct TagHandle<'a, 'b> {
surface: &'b mut Surface<'a>,
/// Whether this tag handle started the marked content sequence, and should
/// thus end it when it is dropped.
started: bool,
}
impl Drop for TagHandle<'_, '_> {
fn drop(&mut self) {
if self.started {
self.surface.end_tagged();
}
}
}
impl<'a> TagHandle<'a, '_> {
pub(crate) fn surface<'c>(&'c mut self) -> &'c mut Surface<'a> {
self.surface
}
}
/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`]
/// when dropped.
pub(crate) fn start_marked<'a, 'b>(
gc: &mut GlobalContext,
surface: &'b mut Surface<'a>,
) -> TagHandle<'a, 'b> {
start_content(gc, surface, ContentTag::Other)
}
/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`]
/// when dropped.
pub(crate) fn start_span<'a, 'b>(
gc: &mut GlobalContext,
surface: &'b mut Surface<'a>,
span: SpanTag,
) -> TagHandle<'a, 'b> {
start_content(gc, surface, ContentTag::Span(span))
}
/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`]
/// when dropped.
pub(crate) fn start_artifact<'a, 'b>(
gc: &mut GlobalContext,
surface: &'b mut Surface<'a>,
kind: ArtifactKind,
) -> TagHandle<'a, 'b> {
let ty = artifact_type(kind);
start_content(gc, surface, ContentTag::Artifact(ty))
}
fn start_content<'a, 'b>(
gc: &mut GlobalContext,
surface: &'b mut Surface<'a>,
content: ContentTag,
) -> TagHandle<'a, 'b> {
let content = if gc.tags.in_artifact.is_some() {
return TagHandle { surface, started: false };
} else if let Some(StackEntryKind::Table(_)) = gc.tags.stack.last().map(|e| &e.kind) {
// Mark any direct child of a table as an aritfact. Any real content
// will be wrapped inside a `TableCell`.
ContentTag::Artifact(ArtifactType::Other)
} else {
content
};
let id = surface.start_tagged(content);
gc.tags.push(TagNode::Leaf(id));
TagHandle { surface, started: true }
}
fn artifact_type(kind: ArtifactKind) -> ArtifactType {
match kind {
ArtifactKind::Header => ArtifactType::Header,
ArtifactKind::Footer => ArtifactType::Footer,
ArtifactKind::Page => ArtifactType::Page,
ArtifactKind::Other => ArtifactType::Other,
}
}
trait PropertyGetAsRef<E, T, const I: u8> {
fn get_as_ref(&self) -> Option<&T>;
}
impl<E, T, const I: u8> PropertyGetAsRef<E, T, I> for Settable<E, I>
where
E: NativeElement,
E: SettableProperty<I, Type = Option<T>>,
E: RefableProperty<I>,
{
fn get_as_ref(&self) -> Option<&T> {
self.get_ref(StyleChain::default()).as_ref()
}
}

View File

@ -0,0 +1,73 @@
use krilla::tagging::TagKind;
use typst_library::foundations::Packed;
use typst_library::model::OutlineEntry;
use crate::tags::TagNode;
#[derive(Debug)]
pub(crate) struct OutlineCtx {
stack: Vec<OutlineSection>,
}
impl OutlineCtx {
pub(crate) fn new() -> Self {
Self { stack: Vec::new() }
}
pub(crate) fn insert(
&mut self,
outline_nodes: &mut Vec<TagNode>,
entry: Packed<OutlineEntry>,
nodes: Vec<TagNode>,
) {
let expected_len = entry.level.get() - 1;
if self.stack.len() < expected_len {
self.stack.resize_with(expected_len, OutlineSection::new);
} else {
while self.stack.len() > expected_len {
self.finish_section(outline_nodes);
}
}
let section_entry = TagNode::Group(TagKind::TOCI.into(), nodes);
self.push(outline_nodes, section_entry);
}
fn finish_section(&mut self, outline_nodes: &mut Vec<TagNode>) {
let sub_section = self.stack.pop().unwrap().into_tag();
self.push(outline_nodes, sub_section);
}
fn push(&mut self, outline_nodes: &mut Vec<TagNode>, entry: TagNode) {
match self.stack.last_mut() {
Some(section) => section.push(entry),
None => outline_nodes.push(entry),
}
}
pub(crate) fn build_outline(mut self, mut outline_nodes: Vec<TagNode>) -> TagNode {
while !self.stack.is_empty() {
self.finish_section(&mut outline_nodes);
}
TagNode::Group(TagKind::TOC.into(), outline_nodes)
}
}
#[derive(Debug)]
pub(crate) struct OutlineSection {
entries: Vec<TagNode>,
}
impl OutlineSection {
const fn new() -> Self {
OutlineSection { entries: Vec::new() }
}
fn push(&mut self, entry: TagNode) {
self.entries.push(entry);
}
fn into_tag(self) -> TagNode {
TagNode::Group(TagKind::TOC.into(), self.entries)
}
}

View File

@ -0,0 +1,582 @@
use std::io::Write as _;
use std::num::NonZeroU32;
use az::SaturatingAs;
use krilla::tagging::{
TableCellSpan, TableDataCell, TableHeaderCell, TagBuilder, TagId, TagKind,
};
use smallvec::SmallVec;
use typst_library::foundations::{Packed, Smart, StyleChain};
use typst_library::model::TableCell;
use typst_library::pdf::{TableCellKind, TableHeaderScope};
use typst_syntax::Span;
use crate::tags::{TableId, TagNode};
#[derive(Debug)]
pub(crate) struct TableCtx {
pub(crate) id: TableId,
pub(crate) summary: Option<String>,
rows: Vec<Vec<GridCell>>,
min_width: usize,
}
impl TableCtx {
pub(crate) fn new(id: TableId, summary: Option<String>) -> Self {
Self { id, summary, rows: Vec::new(), min_width: 0 }
}
fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> {
let cell = self.rows.get(y)?.get(x)?;
self.resolve_cell(cell)
}
fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut TableCtxCell> {
let cell = self.rows.get_mut(y)?.get_mut(x)?;
match cell {
GridCell::Cell(cell) => {
// HACK: Workaround for the second mutable borrow when resolving
// the spanned cell.
Some(unsafe { std::mem::transmute(cell) })
}
&mut GridCell::Spanned(x, y) => self.rows[y][x].as_cell_mut(),
GridCell::Missing => None,
}
}
pub(crate) fn contains(&self, cell: &Packed<TableCell>) -> bool {
let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
self.get(x, y).is_some()
}
fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> {
match cell {
GridCell::Cell(cell) => Some(cell),
&GridCell::Spanned(x, y) => self.rows[y][x].as_cell(),
GridCell::Missing => None,
}
}
pub(crate) fn insert(&mut self, cell: &Packed<TableCell>, nodes: Vec<TagNode>) {
let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let rowspan = cell.rowspan.get(StyleChain::default());
let colspan = cell.colspan.get(StyleChain::default());
let kind = cell.kind.get(StyleChain::default());
// Extend the table grid to fit this cell.
let required_height = y + rowspan.get();
self.min_width = self.min_width.max(x + colspan.get());
if self.rows.len() < required_height {
self.rows
.resize(required_height, vec![GridCell::Missing; self.min_width]);
}
for row in self.rows.iter_mut() {
if row.len() < self.min_width {
row.resize_with(self.min_width, || GridCell::Missing);
}
}
// Store references to the cell for all spanned cells.
for i in y..y + rowspan.get() {
for j in x..x + colspan.get() {
self.rows[i][j] = GridCell::Spanned(x, y);
}
}
self.rows[y][x] = GridCell::Cell(TableCtxCell {
x: x.saturating_as(),
y: y.saturating_as(),
rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX),
colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX),
kind,
headers: SmallVec::new(),
nodes,
span: cell.span(),
});
}
pub(crate) fn build_table(mut self, mut nodes: Vec<TagNode>) -> TagNode {
// Table layouting ensures that there are no overlapping cells, and that
// any gaps left by the user are filled with empty cells.
if self.rows.is_empty() {
return TagNode::Group(TagKind::Table(self.summary).into(), nodes);
}
let height = self.rows.len();
let width = self.rows[0].len();
// Only generate row groups such as `THead`, `TFoot`, and `TBody` if
// there are no rows with mixed cell kinds.
let mut gen_row_groups = true;
let row_kinds = (self.rows.iter())
.map(|row| {
row.iter()
.filter_map(|cell| self.resolve_cell(cell))
.map(|cell| cell.kind)
.fold(Smart::Auto, |a, b| {
if let Smart::Custom(TableCellKind::Header(_, scope)) = b {
gen_row_groups &= scope == TableHeaderScope::Column;
}
if let (Smart::Custom(a), Smart::Custom(b)) = (a, b) {
gen_row_groups &= a == b;
}
a.or(b)
})
.unwrap_or(TableCellKind::Data)
})
.collect::<Vec<_>>();
// Fixup all missing cell kinds.
for (row, row_kind) in self.rows.iter_mut().zip(row_kinds.iter().copied()) {
let default_kind =
if gen_row_groups { row_kind } else { TableCellKind::Data };
for cell in row.iter_mut() {
let Some(cell) = cell.as_cell_mut() else { continue };
cell.kind = cell.kind.or(Smart::Custom(default_kind));
}
}
// Explicitly set the headers attribute for cells.
for x in 0..width {
let mut column_header = Vec::new();
for y in 0..height {
self.resolve_cell_headers(
(x, y),
&mut column_header,
TableHeaderScope::refers_to_column,
);
}
}
for y in 0..height {
let mut row_header = Vec::new();
for x in 0..width {
self.resolve_cell_headers(
(x, y),
&mut row_header,
TableHeaderScope::refers_to_row,
);
}
}
let mut chunk_kind = row_kinds[0];
let mut row_chunk = Vec::new();
for (row, row_kind) in self.rows.into_iter().zip(row_kinds) {
let row_nodes = row
.into_iter()
.filter_map(|cell| {
let cell = cell.into_cell()?;
let span = TableCellSpan { rows: cell.rowspan, cols: cell.colspan };
let tag = match cell.unwrap_kind() {
TableCellKind::Header(_, scope) => {
let id = table_cell_id(self.id, cell.x, cell.y);
let scope = table_header_scope(scope);
TagKind::TH(
TableHeaderCell::new(scope)
.with_span(span)
.with_headers(cell.headers),
)
.with_id(Some(id))
.with_location(Some(cell.span.into_raw().get()))
}
TableCellKind::Footer | TableCellKind::Data => TagKind::TD(
TableDataCell::new()
.with_span(span)
.with_headers(cell.headers),
)
.with_location(Some(cell.span.into_raw().get())),
};
Some(TagNode::Group(tag, cell.nodes))
})
.collect();
let row = TagNode::Group(TagKind::TR.into(), row_nodes);
// Push the `TR` tags directly.
if !gen_row_groups {
nodes.push(row);
continue;
}
// Generate row groups.
if !should_group_rows(chunk_kind, row_kind) {
let tag = match chunk_kind {
TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
};
nodes.push(TagNode::Group(tag.into(), std::mem::take(&mut row_chunk)));
chunk_kind = row_kind;
}
row_chunk.push(row);
}
if !row_chunk.is_empty() {
let tag = match chunk_kind {
TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
};
nodes.push(TagNode::Group(tag.into(), row_chunk));
}
TagNode::Group(TagKind::Table(self.summary).into(), nodes)
}
fn resolve_cell_headers<F>(
&mut self,
(x, y): (usize, usize),
current_header: &mut Vec<(NonZeroU32, TagId)>,
refers_to_dir: F,
) where
F: Fn(&TableHeaderScope) -> bool,
{
let table_id = self.id;
let Some(cell) = self.get_mut(x, y) else { return };
let mut new_header = None;
if let TableCellKind::Header(level, scope) = cell.unwrap_kind() {
if refers_to_dir(&scope) {
// Remove all headers that are the same or a lower level.
while current_header.pop_if(|(l, _)| *l >= level).is_some() {}
let tag_id = table_cell_id(table_id, cell.x, cell.y);
new_header = Some((level, tag_id));
}
}
if let Some((_, cell_id)) = current_header.last() {
if !cell.headers.contains(&cell_id) {
cell.headers.push(cell_id.clone());
}
}
current_header.extend(new_header);
}
}
#[derive(Clone, Debug, Default)]
enum GridCell {
Cell(TableCtxCell),
Spanned(usize, usize),
#[default]
Missing,
}
impl GridCell {
fn as_cell(&self) -> Option<&TableCtxCell> {
if let Self::Cell(v) = self { Some(v) } else { None }
}
fn as_cell_mut(&mut self) -> Option<&mut TableCtxCell> {
if let Self::Cell(v) = self { Some(v) } else { None }
}
fn into_cell(self) -> Option<TableCtxCell> {
if let Self::Cell(v) = self { Some(v) } else { None }
}
}
#[derive(Clone, Debug)]
struct TableCtxCell {
x: u32,
y: u32,
rowspan: NonZeroU32,
colspan: NonZeroU32,
kind: Smart<TableCellKind>,
headers: SmallVec<[TagId; 1]>,
nodes: Vec<TagNode>,
span: Span,
}
impl TableCtxCell {
fn unwrap_kind(&self) -> TableCellKind {
self.kind.unwrap_or_else(|| unreachable!())
}
}
fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool {
match (a, b) {
(TableCellKind::Header(..), TableCellKind::Header(..)) => true,
(TableCellKind::Footer, TableCellKind::Footer) => true,
(TableCellKind::Data, TableCellKind::Data) => true,
(_, _) => false,
}
}
fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId {
let mut buf = SmallVec::<[u8; 32]>::new();
_ = write!(&mut buf, "{}x{x}y{y}", table_id.0);
TagId::from(buf)
}
fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope {
match scope {
TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both,
TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column,
TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use typst_library::foundations::Content;
use super::*;
#[track_caller]
fn test(table: TableCtx, exp_tag: TagNode) {
let tag = table.build_table(Vec::new());
assert_eq!(exp_tag, tag);
}
#[track_caller]
fn table<const SIZE: usize>(cells: [TableCell; SIZE]) -> TableCtx {
let mut table = TableCtx::new(TableId(324), Some("summary".into()));
for cell in cells {
table.insert(&Packed::new(cell), Vec::new());
}
table
}
#[track_caller]
fn header_cell(
(x, y): (usize, usize),
level: u32,
scope: TableHeaderScope,
) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Custom(TableCellKind::Header(
NonZeroU32::new(level).unwrap(),
scope,
)))
}
#[track_caller]
fn footer_cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Custom(TableCellKind::Footer))
}
fn cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Custom(TableCellKind::Data))
}
fn empty_cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Auto)
}
fn table_tag<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
let tag = TagKind::Table(Some("summary".into()));
TagNode::Group(tag.into(), nodes.into())
}
fn thead<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::THead.into(), nodes.into())
}
fn tbody<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TBody.into(), nodes.into())
}
fn tfoot<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TFoot.into(), nodes.into())
}
fn trow<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TR.into(), nodes.into())
}
fn th<const SIZE: usize>(
(x, y): (u32, u32),
scope: TableHeaderScope,
headers: [(u32, u32); SIZE],
) -> TagNode {
let scope = table_header_scope(scope);
let id = table_cell_id(TableId(324), x, y);
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group(
TagKind::TH(TableHeaderCell::new(scope).with_headers(ids))
.with_id(Some(id))
.with_location(Some(Span::detached().into_raw().get())),
Vec::new(),
)
}
fn td<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode {
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group(
TagKind::TD(TableDataCell::new().with_headers(ids))
.with_location(Some(Span::detached().into_raw().get())),
Vec::new(),
)
}
#[test]
fn simple_table() {
#[rustfmt::skip]
let table = table([
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
cell(0, 1),
cell(1, 1),
cell(2, 1),
cell(0, 2),
cell(1, 2),
cell(2, 2),
]);
#[rustfmt::skip]
let tag = table_tag([
thead([trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
])]),
tbody([
trow([
td([(0, 0)]),
td([(1, 0)]),
td([(2, 0)]),
]),
trow([
td([(0, 0)]),
td([(1, 0)]),
td([(2, 0)]),
]),
]),
]);
test(table, tag);
}
#[test]
fn header_row_and_column() {
#[rustfmt::skip]
let table = table([
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
header_cell((0, 1), 1, TableHeaderScope::Row),
cell(1, 1),
cell(2, 1),
header_cell((0, 2), 1, TableHeaderScope::Row),
cell(1, 2),
cell(2, 2),
]);
#[rustfmt::skip]
let tag = table_tag([
trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
]),
trow([
th((0, 1), TableHeaderScope::Row, [(0, 0)]),
td([(1, 0), (0, 1)]),
td([(2, 0), (0, 1)]),
]),
trow([
th((0, 2), TableHeaderScope::Row, [(0, 0)]),
td([(1, 0), (0, 2)]),
td([(2, 0), (0, 2)]),
]),
]);
test(table, tag);
}
#[test]
fn complex_tables() {
#[rustfmt::skip]
let table = table([
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
header_cell((0, 1), 2, TableHeaderScope::Column),
header_cell((1, 1), 2, TableHeaderScope::Column),
header_cell((2, 1), 2, TableHeaderScope::Column),
cell(0, 2),
empty_cell(1, 2), // the type of empty cells is inferred from the row
cell(2, 2),
header_cell((0, 3), 2, TableHeaderScope::Column),
header_cell((1, 3), 2, TableHeaderScope::Column),
empty_cell(2, 3), // the type of empty cells is inferred from the row
cell(0, 4),
cell(1, 4),
empty_cell(2, 4),
empty_cell(0, 5), // the type of empty cells is inferred from the row
footer_cell(1, 5),
footer_cell(2, 5),
]);
#[rustfmt::skip]
let tag = table_tag([
thead([
trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
]),
trow([
th((0, 1), TableHeaderScope::Column, [(0, 0)]),
th((1, 1), TableHeaderScope::Column, [(1, 0)]),
th((2, 1), TableHeaderScope::Column, [(2, 0)]),
]),
]),
tbody([
trow([
td([(0, 1)]),
td([(1, 1)]),
td([(2, 1)]),
]),
]),
thead([
trow([
th((0, 3), TableHeaderScope::Column, [(0, 0)]),
th((1, 3), TableHeaderScope::Column, [(1, 0)]),
th((2, 3), TableHeaderScope::Column, [(2, 0)]),
]),
]),
tbody([
trow([
td([(0, 3)]),
td([(1, 3)]),
td([(2, 3)]),
]),
]),
tfoot([
trow([
td([(0, 3)]),
td([(1, 3)]),
td([(2, 3)]),
]),
]),
]);
test(table, tag);
}
}

View File

@ -11,8 +11,8 @@ use typst_library::visualize::FillRule;
use typst_syntax::Span; use typst_syntax::Span;
use crate::convert::{FrameContext, GlobalContext}; use crate::convert::{FrameContext, GlobalContext};
use crate::paint;
use crate::util::{AbsExt, TransformExt, display_font}; use crate::util::{AbsExt, TransformExt, display_font};
use crate::{paint, tags};
#[typst_macros::time(name = "handle text")] #[typst_macros::time(name = "handle text")]
pub(crate) fn handle_text( pub(crate) fn handle_text(
@ -23,6 +23,9 @@ pub(crate) fn handle_text(
) -> SourceResult<()> { ) -> SourceResult<()> {
*gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len();
let mut handle = tags::start_marked(gc, surface);
let surface = handle.surface();
let font = convert_font(gc, t.font.clone())?; let font = convert_font(gc, t.font.clone())?;
let fill = paint::convert_fill( let fill = paint::convert_fill(
gc, gc,