mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e5bb4551a1
commit
3d4bb45f9e
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user