diff --git a/ROADMAP.md b/ROADMAP.md
index 242ed64d..684b6021 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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.
diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs
index 8496ade6..c3e1330b 100644
--- a/crates/engine/src/cmd/style.rs
+++ b/crates/engine/src/cmd/style.rs
@@ -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("
Origins
"), "{html}");
assert!(html.contains("word"), "{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));
}
}
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 8464c2e7..ac48d268 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -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,
story_tags: HashMap>,
+ /// 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,
+ /// Marked content of each (story, paragraph index).
+ pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>,
}
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 = 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::().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 {
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index 8b03b6d7..d95dce90 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -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.
diff --git a/docs/parity.md b/docs/parity.md
index c5b8ec3f..1570084c 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -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 |