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 |