A: tagged PDF by paragraph, with headings from Export Tagging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 10:48:58 -04:00
co-authored by Claude Opus 5.5
parent e5bb4551a1
commit 3d4bb45f9e
5 changed files with 113 additions and 33 deletions
+1
View File
@@ -112,6 +112,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
+6
View File
@@ -1094,5 +1094,11 @@ mod export_tag_tests {
let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(html.contains("<h1 class=\"chapter\">Origins</h1>"), "{html}");
assert!(html.contains("<strong class=\"key-term\">word</strong>"), "{html}");
// Tagged PDF: a heading element and a paragraph element.
let r = s.execute("file.exportPdf", &json!({"tagged": true, "compress": false})).unwrap();
let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap());
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements");
assert!(designcraft_render::pdf_page_count(&pdf) == Some(1));
}
}
+52 -11
View File
@@ -18,7 +18,7 @@ use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, SpanTag, Tag, TagGroup, TagTree};
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Tag, TagGroup, TagTree};
use crate::{ExportReport, PdfError, PdfOptions, Result, Standard};
@@ -154,6 +154,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
rgb_only: archival.is_some(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
};
if ex.rgb_only {
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
@@ -302,6 +304,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
rgb_only: false,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
};
let pairs = booklet_pairs(n, opts.kind);
// Sheet size from the largest page.
@@ -387,6 +391,11 @@ pub(crate) struct Exporter<'a> {
/// Tagged PDF: the structure in reading order (stories gather their frames' content).
tags: Vec<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
/// While drawing a story's frame for the structure tree: the story (text runs are tagged
/// per paragraph, everything else in the frame is an artifact).
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
/// Marked content of each (story, paragraph index).
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
}
enum TagEntry {
@@ -602,14 +611,14 @@ impl Exporter<'_> {
return;
}
if let Some(sid) = story {
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
self.item(s, it, parent, page_name);
s.end_tagged();
let v = self.story_tags.entry(sid).or_default();
if v.is_empty() {
// Text runs tag themselves by paragraph; the rest of the frame is an artifact.
if let std::collections::hash_map::Entry::Vacant(e) = self.story_tags.entry(sid) {
e.insert(Vec::new());
self.tags.push(TagEntry::Story(sid));
}
v.push(id);
self.tag_story = Some(sid);
self.item(s, it, parent, page_name);
self.tag_story = None;
return;
}
let id = s.start_tagged(ContentTag::Other);
@@ -628,11 +637,30 @@ impl Exporter<'_> {
for e in std::mem::take(&mut self.tags) {
match e {
TagEntry::Story(sid) => {
let mut g = TagGroup::new(Tag::P);
for id in self.story_tags.remove(&sid).unwrap_or_default() {
g.push(id);
// One structure element per paragraph: headings and quotes from the
// paragraph style's Export Tagging, else P.
let mut paras: Vec<usize> = self.para_tags.keys().filter(|(s, _)| *s == sid).map(|(_, p)| *p).collect();
paras.sort_unstable();
let story = self.doc.story(sid);
for pi in paras {
let ids = self.para_tags.remove(&(sid, pi)).unwrap_or_default();
let style = story.and_then(|st| st.paras.get(pi)).map(|p| p.style.clone()).unwrap_or_default();
let tag = self.doc.styles.export_tag(&style, false).map(|e| e.tag.clone()).unwrap_or_default();
let text = || story.and_then(|st| st.para_ranges().get(pi).map(|r| st.text[r.clone()].trim().to_string()));
let kind: krilla::tagging::TagKind = match tag.as_str() {
h if h.len() == 2 && h.starts_with('h') => {
let n = h[1..].parse::<u16>().unwrap_or(1).clamp(1, 6);
Tag::Hn(std::num::NonZeroU16::new(n).expect("1–6"), text()).into()
}
"blockquote" => Tag::BlockQuote.into(),
_ => Tag::P.into(),
};
let mut g = TagGroup::new(kind);
for id in ids {
g.push(id);
}
tree.push(g);
}
tree.push(g);
}
TagEntry::Figure(g) => tree.push(g),
}
@@ -763,6 +791,10 @@ impl Exporter<'_> {
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let path = to_path(&bp);
let pushes = Self::push_group(s, it.opacity, it.blend);
let frame_art = self.tag_story.is_some();
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
// Drop shadow (simple offset silhouette, like the renderer).
if ds.on
&& let Some(p) = &path
@@ -792,6 +824,9 @@ impl Exporter<'_> {
s.draw_path(p);
s.set_fill(None);
}
if frame_art {
s.end_tagged();
}
// Content.
match &it.content {
Content::Graphic(g) => {
@@ -832,7 +867,13 @@ impl Exporter<'_> {
if !it.stroke.is_none()
&& let Some(p) = &path
{
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
self.stroke(s, it, &bp, p);
if frame_art {
s.end_tagged();
}
}
s.pop();
for _ in 0..pushes {
+51 -19
View File
@@ -17,6 +17,7 @@ use designcraft_geom::{Affine, BezPath, Rect};
use krilla::geom::Point;
use krilla::paint::Stroke;
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag};
use krilla::text::{Font, GlyphId, KrillaGlyph};
use crate::export::{Exporter, solid_fill, tf, to_path};
@@ -65,16 +66,45 @@ impl Exporter<'_> {
.clone()
}
/// Draw `f` as an artifact while a story is being tagged (nested text isn't tagged).
fn as_artifact(&mut self, s: &mut Surface, f: impl FnOnce(&mut Self, &mut Surface)) {
match self.tag_story.take() {
Some(sid) => {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
f(self, s);
s.end_tagged();
self.tag_story = Some(sid);
}
None => f(self, s),
}
}
/// A glyph run, tagged under its paragraph while a story is being tagged.
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
match self.tag_story {
Some(sid) => {
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(gs[0].byte.min(st.len())));
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
self.run(s, cs, gs, baseline, story);
s.end_tagged();
self.para_tags.entry((sid, pi)).or_default().push(id);
}
None => self.run(s, cs, gs, baseline, story),
}
}
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
// Paragraph shading and rules under the text.
for d in &ft.decos {
if let Some(c) = self.swatch_color(&d.color, d.tint) {
self.fill_rect(s, d.rect, c);
self.as_artifact(s, |me, s| {
for d in &ft.decos {
if let Some(c) = me.swatch_color(&d.color, d.tint) {
me.fill_rect(s, d.rect, c);
}
}
}
if !ft.tables.is_empty() {
self.tables(s, ft);
}
if !ft.tables.is_empty() {
me.tables(s, ft);
}
});
let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
for l in &ft.lines {
let gs = &l.glyphs;
@@ -89,7 +119,7 @@ impl Exporter<'_> {
if ft.vertical && g.upright {
let c = designcraft_geom::Point::new(g.x + g.adv / 2.0, l.baseline + g.y - g.adv * 0.38);
s.push_transform(&crate::export::tf(Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c)));
self.run(s, cs, &gs[i..i + 1], l.baseline, story);
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
s.pop();
i += 1;
continue;
@@ -98,7 +128,7 @@ impl Exporter<'_> {
while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && gs[j].upright) {
j += 1;
}
self.run(s, cs, &gs[i..j], l.baseline, story);
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
// Underline / strikethrough, merged per style along the line.
@@ -114,18 +144,20 @@ impl Exporter<'_> {
}
}
}
for (rule, r) in deco {
if let Some(c) = self.swatch_color(&rule.color, rule.tint) {
self.fill_rect(s, r, c);
self.as_artifact(s, |me, s| {
for (rule, r) in deco {
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
me.fill_rect(s, r, c);
}
}
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
self.frame_text(s, &n.text, nft, &n.source);
s.pop();
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
me.frame_text(s, &n.text, nft, &n.source);
s.pop();
}
}
}
});
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
+3 -3
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P2 | 76 | 55 | 21 | 0 | 86% |
| P2 | 76 | 56 | 20 | 0 | 87% |
| Area | Parity |
|---|---|
@@ -33,7 +33,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| XML & automation | 83% |
| View & navigation | 96% |
| Undo, history, saving | 100% |
| Accessibility | 90% |
| Accessibility | 100% |
Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print).
@@ -312,7 +312,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Undo, history, saving | Unlimited undo/redo | P0 | D |
| Undo, history, saving | Revert | P0 | D |
| Undo, history, saving | Save / Save As / Save a Copy | P0 | D |
| Accessibility | Tagged PDF export | P2 | P |
| Accessibility | Tagged PDF export | P2 | D |
| Accessibility | Alt text | P2 | D |
| Accessibility | Keyboard navigation of UI | P1 | D |
| Accessibility | High contrast | P2 | D |