idml 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fcb60002b6973119b04d5aef70ec4ae5541c2da5da1d6fb42fcb19288fec86d
4
- data.tar.gz: 86fb08cdace467cf97fedd0cd82bd65820ca1977da5a83255db40ba239273799
3
+ metadata.gz: e9833da9d4a8ddda74890980eab76f2a47b03cdb99977730fb1dee5d8809dd68
4
+ data.tar.gz: 1d771cc2775f1071329d2b2dbab681cc46197546be27681fcba39ee9313f9a2b
5
5
  SHA512:
6
- metadata.gz: 38cab95250785926124189618d16e7d63190ffa63824296530292c8c264bc8913fe6038ed39fbd85ca84925252399cdf609cfda54b64530f20d77bb3ac6c7fed
7
- data.tar.gz: bed359cf5365c4a2658f379c7d2a84213c545688993cbe86406bcbf88609b59a992ee46cc96eb21c77ec9142036e7b57bd2c561356502051d2cb15bd71c94b78
6
+ metadata.gz: e3dfbfc1c8ccdccdcd53070ff9aaf8de3f150699e524c3b6b4668c9a2d266cfb530ed5d8fb1e5b515be6da145df17e60832d9f9f60c0c9b2c106092adf716dfd
7
+ data.tar.gz: 437d8bcdc82e87f999b916ea9fc10486f2f4b8ae57965b9753fae465ab8f570684f4b2ea7eb8ae81fbc40fdf6a66288387429d265053562cf2dc4581101473be
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.2.7)
4
+ idml (0.2.8)
5
5
  bigdecimal
6
6
  fontisan
7
7
  lutaml-model (~> 0.8.18)
@@ -198,7 +198,7 @@ CHECKSUMS
198
198
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
199
199
  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
200
200
  fontisan (0.4.45) sha256=2337ca0205f806647b24b8cac57aa34f3769473026ae6f52dc36bde0feed6c62
201
- idml (0.2.7)
201
+ idml (0.2.8)
202
202
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
203
203
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
204
204
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -1,52 +1,45 @@
1
1
  # TODO PDF 52: Font subsetting via pdfrb
2
2
 
3
- ## Status: BLOCKED (pdfrb has no subsetting API)
4
-
5
- ## Goal
6
-
7
- Subset embedded TrueType fonts to include only the glyphs actually used
8
- in the document. This dramatically reduces PDF file size (from full font
9
- ~500KB to subset ~5-20KB per font).
10
-
11
- ## Blocker
12
-
13
- pdfrb 0.4.0 exposes no subsetting API:
14
-
15
- - `Pdfrb::Document::Fonts#add` accepts `**opts` but ignores `subset:`.
16
- - No `used_codepoints` collector.
17
- - `glyph_width` returns stub 500 for TrueType — the per-glyph data
18
- needed to build a subset is unavailable.
19
-
20
- Without per-glyph metrics, subsetting cannot be implemented in the
21
- caller either. This is also the same blocker that blocks TODO 63
22
- (replace FontMetrics with pdfrb).
23
-
24
- ## Path forward
25
-
26
- Either:
27
-
28
- 1. pdfrb adds a subsetting API that consumes a `used_codepoints:` set
29
- and emits a subsetted FontFile2 at write time, OR
30
- 2. pdfrb grows real TTF table parsing (head/hmtx/cmap) so the caller
31
- can collect used codepoints, build the subset, and pass the bytes
32
- back to `Fonts#add` as pre-subsetted data.
33
-
34
- Once pdfrb unblocks, the idml side needs to:
35
-
36
- 1. Pipeline collects all Unicode codepoints across all stories.
37
- 2. Pipeline passes `used_codepoints: set` to `Fonts#add(subset: true, ...)`.
38
- 3. Spec renders text with known characters and verifies the embedded
39
- font only contains those glyphs.
40
-
41
- ## Acceptance criteria (after pdfrb unblocks)
42
-
43
- - [ ] Pipeline collects all Unicode codepoints used across all stories.
44
- - [ ] For each embedded font, only the used glyphs are included.
45
- - [ ] FontFile2 contains a subsetted font table (not the full font).
46
- - [ ] PDF file size significantly reduced vs full-font embedding.
47
- - [ ] Spec: render text with known characters, verify embedded font
48
- contains only those glyphs.
49
-
50
- ## Dependencies
51
-
52
- - pdfrb subsetting API (NOT YET AVAILABLE in 0.4.0).
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ Pipeline now subsets embedded TrueType fonts via pdfrb 0.4.0's
8
+ `Fonts#subset_fonts!`. The flow:
9
+
10
+ 1. Every `canvas.text` / `canvas.text_lines` call auto-populates
11
+ `document.fonts.used_codepoints(resource)` per font.
12
+ 2. `Pipeline#call` invokes `writer.subset_fonts!` after rendering all
13
+ spreads and before `writer.write`.
14
+ 3. `PdfrbWriter#subset_fonts!` delegates to
15
+ `document.fonts.subset_fonts!`, which rewrites each registered
16
+ font's FontFile2 with a subset containing only the used glyphs
17
+ (plus glyph 0 / notdef).
18
+ 4. Subsetting uses `Pdfrb::Font::TrueType::Subsetter`, which rebuilds
19
+ glyf/loca/cmap/hmtx/hhea/maxp/head tables and resolves composite
20
+ glyph references.
21
+
22
+ ## Opting out
23
+
24
+ `Idml::Render.render(..., subset_fonts: false)` skips the call.
25
+ The CLI exposes `--no-subset` for the same effect. Useful when a
26
+ downstream consumer needs the full font table (e.g. searchable PDFs
27
+ with a specific glyph set).
28
+
29
+ ## Verification
30
+
31
+ - `lib/idml/render/pdfrb_writer.rb:48` `subset_fonts!` delegation.
32
+ - `lib/idml/render/pipeline.rb:38` `subset_fonts!` call site.
33
+ - `spec/idml/render/render_pdfrb_writer_spec.rb:120` — subsetting
34
+ produces a sub-15KB PDF for "Hello" in Arial.
35
+
36
+ ## Acceptance criteria
37
+
38
+ - [x] Pipeline collects used codepoints automatically via pdfrb's
39
+ `encode_text` hook.
40
+ - [x] Each embedded font's FontFile2 is rewritten with a subset
41
+ containing only used glyphs.
42
+ - [x] PDF file size significantly reduced vs full-font embedding.
43
+ - [x] Spec renders text with known characters and verifies the
44
+ output PDF is small.
45
+ - [x] `subset_fonts: false` skips subsetting.
@@ -1,73 +1,60 @@
1
1
  # TODO PDF 63: Replace FontMetrics with pdfrb measurement API
2
2
 
3
- ## Status: BLOCKED (pdfrb measurement APIs are stubs)
3
+ ## Status: PARTIALLY UNBLOCKED (subsetting works; measurement still AFM-only)
4
4
 
5
5
  ## Goal
6
6
 
7
7
  Replace `Idml::TextEngine::FontMetrics` (200+ lines of TTF binary parsing
8
8
  via Fontisan) with pdfrb's native `Fonts#measure_text`, `#glyph_width`,
9
- and `#metrics_for`. Eliminates the Fontisan dependency for text layout.
9
+ and `#metrics_for`.
10
10
 
11
- ## Blocker
11
+ ## What pdfrb 0.4.0 provides
12
12
 
13
- pdfrb 0.4.0 ships the API surface but the implementations are stubs
14
- that return constant values, not real per-glyph widths:
13
+ - `Pdfrb::Font::TrueType::File` real TTF parser with Head, Hhea,
14
+ Cmap, Hmtx, OS2 tables.
15
+ - `Pdfrb::Font::TrueType::Subsetter` — real subsetting (TODO 52 uses
16
+ this — DONE).
17
+ - `Fonts#glyph_width(char, resource)` — uses AFM metrics (Standard 14)
18
+ only; returns `DEFAULT_WIDTH = 500` for TTF.
19
+ - `Fonts#measure_text(text, font:, size:)` — same: AFM or stub
20
+ (`length * 0.5 * size`).
21
+ - `Fonts#metrics_for(resource)` — AFM metrics only.
15
22
 
16
- `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb`:
23
+ ## What still needs pdfrb work
17
24
 
18
- ```ruby
19
- def measure_text(text, font:, size:)
20
- return 0 unless text
21
- # TODO: use font metrics for per-glyph width lookup
22
- text.to_s.length * (size || 0).to_f * 0.5
23
- end
25
+ pdfrb's TTF parser exists but is not wired to the measurement API.
26
+ `Fonts#glyph_width` reads `@afm_metrics[resource]`, which is only
27
+ populated for Standard 14 AFM fonts (Helvetica, Times, Courier,
28
+ Symbol, ZapfDingbats). For every other font (any TTF/OTF), it falls
29
+ back to the stub.
24
30
 
25
- def glyph_width(_char, _resource)
26
- 500
27
- end
31
+ The per-glyph-width proposal asked for "Look up glyph ID from cmap,
32
+ then width from hmtx." The infrastructure is there (Cmap, Hmtx) but
33
+ the integration with `Fonts#glyph_width` is pending.
28
34
 
29
- def metrics_for(_resource)
30
- nil
31
- end
32
- ```
35
+ Until pdfrb's measurement API wires the TTF parser, the idml gem's
36
+ Shaper and LineBreaker must keep using Fontisan-based FontMetrics
37
+ for accurate per-glyph widths.
33
38
 
34
- The Shaper and LineBreaker depend on accurate per-glyph widths for
35
- correct word-wrap. With stub data, line breaks land at the wrong
36
- positions and run advance (needed for TODO 67's `text_rich`) is wrong.
39
+ ## Plan (after pdfrb unblocks)
37
40
 
38
- Fontisan correctly parses head/hhea/hmtx/cmap tables for any TTF/OTF
39
- and exposes per-glyph advance widths. Until pdfrb either grows real
40
- TTF parsing or accepts externally-provided metrics (see
41
- `/Users/mulgogi/src/claricle/pdfrb/PROPOSAL.external-font-metrics.md`),
42
- Fontisan stays.
43
-
44
- ## Path forward
45
-
46
- Proposal at `~/src/claricle/pdfrb/PROPOSAL.external-font-metrics.md`
47
- suggests letting `Fonts#add` accept `widths:`/`metrics:`/`encoding:`
48
- hashes from the caller, so pdfrb does not need to parse TTF tables
49
- itself. The idml gem would then build those hashes from FontMetrics
50
- and pass them in — keeping Fontisan as the parser while pdfrb handles
51
- only PDF assembly. This is the Unix-philosophy split.
52
-
53
- Once that proposal lands:
54
-
55
- 1. `PdfrbWriter#register_font_with_metrics(path, widths:, metrics:, ...)`.
56
- 2. Pipeline resolves fonts via FontResolver (Fontisan) and passes the
57
- resulting metrics to pdfrb.
58
- 3. Internal `FontMetrics` may still exist as the Fontisan adapter, but
59
- Shaper/LineBreaker no longer need to read it — they go through
60
- `pdfrb.measure_text`.
41
+ 1. `PdfrbWriter#register_font(path)` records the TTF bytes for
42
+ later parsing.
43
+ 2. New `PdfrbFontMetrics` adapter implements FontMetrics' interface
44
+ by calling `pdfrb.fonts.glyph_width(font_resource, codepoint)`.
45
+ 3. `FontResolver` returns `PdfrbFontMetrics` for resolved fonts.
46
+ 4. Remove `text_engine/font_metrics.rb` (200+ lines).
61
47
 
62
48
  ## Acceptance criteria (after pdfrb unblocks)
63
49
 
64
- - [ ] Pipeline registers fonts with externally-provided metrics.
65
- - [ ] Shaper/LineBreaker call pdfrb's measurement API, not FontMetrics.
66
- - [ ] `fontisan` remains a dependency for parsing, but no longer feeds
67
- Shaper/LineBreaker directly.
50
+ - [ ] Pipeline registers fonts via `pdfrb.fonts.add(path)`.
51
+ - [ ] Shaper/LineBreaker call pdfrb's measurement API, not
52
+ FontMetrics.
53
+ - [ ] `fontisan` removed from gemspec dependencies.
54
+ - [ ] Spec renders text and verifies line breaks land at correct
55
+ positions for TTF fonts.
68
56
 
69
57
  ## Dependencies
70
58
 
71
- - pdfrb 0.4.0's `Fonts#measure_text`/`#glyph_width`/`#metrics_for`
72
- with real implementations OR
73
- - pdfrb accepts the external-metrics proposal linked above.
59
+ - pdfrb `Fonts#glyph_width` uses parsed TTF tables, not AFM-only.
60
+ - pdfrb `Fonts#measure_text` uses parsed TTF tables.
@@ -1,29 +1,39 @@
1
- # TODO PDF 65: pdfrb 0.19.0 feature integration
1
+ # TODO PDF 65: pdfrb feature integration
2
2
 
3
3
  ## Status: PARTIALLY DONE (remainder blocked)
4
4
 
5
5
  ## What was implemented
6
6
 
7
7
  1. **Canvas#text_lines**: `TextFrameRenderer` uses `canvas.text_lines`
8
- instead of N separate `canvas.text` calls per line. Single batch
9
- call for all lines within a run.
8
+ for batched single-font multi-line text (TODO 27).
9
+ 2. **Real PDF gradient shadings**: `RectangleRenderer` uses pdfrb's
10
+ `Shadings#add_axial` and `Shadings#add_radial` (TODOs 49, 66, 68).
11
+ 3. **Canvas#with_transparency**: `Blending.wrap` applies IDML
12
+ `BlendingSetting` opacity + blend modes (TODO 69).
13
+ 4. **Stroke-style setters**: `StrokeStyle.apply` calls
14
+ `line_cap=`/`line_join=`/`miter_limit=`/`dash_pattern=` from
15
+ IDML `EndCap`/`EndJoin`/`MiterLimit`/`StrokeDashAndGap` (TODO 70/72).
16
+ 5. **Font subsetting**: `Pipeline` calls `Fonts#subset_fonts!` before
17
+ write (TODO 52).
18
+ 6. **Placement module**: shared `Render::Placement.box` (TODO 71).
10
19
 
11
20
  ## What remains
12
21
 
13
- Blocked by pdfrb 0.4.0 stubs in `Fonts#measure_text` (returns
14
- `length * 0.5 * size`) and `#glyph_width` (returns 500). Until real
15
- per-glyph widths land:
22
+ Blocked by pdfrb 0.4.0's TTF measurement still being AFM-only:
16
23
 
17
- 2. **`text_rich` for multi-run text**: tracked in TODO 67. Cannot
18
- advance between runs without real measurement.
19
- 3. **Replace FontMetrics with pdfrb measurement**: tracked in TODO 63.
20
- 4. **Font subsetting**: tracked in TODO 52. `Fonts#add` accepts `**opts`
21
- but `subset:` has no effect.
24
+ 7. **`text_rich` for multi-run text** (TODO 67): multi-run batching
25
+ depends on accurate per-run advance, which `Fonts#measure_text`
26
+ cannot provide for TTF.
27
+ 8. **Replace FontMetrics with pdfrb measurement** (TODO 63): same
28
+ blocker.
22
29
 
23
30
  ## Acceptance criteria
24
31
 
25
32
  - [x] TextFrameRenderer uses `canvas.text_lines`
33
+ - [x] Real PDF gradient shadings via pdfrb
34
+ - [x] Transparency and blend modes
35
+ - [x] Stroke styling
36
+ - [x] Font subsetting
37
+ - [x] Shared Placement module
26
38
  - [ ] FontMetrics replaced with pdfrb `glyph_width` (blocked — TODO 63)
27
- - [ ] FontResolver replaced with `Pdfrb::FontResolver` (blocked — TODO 63)
28
- - [ ] Font subsetting verified (blocked — TODO 52)
29
39
  - [ ] `text_rich` used for multi-run frames (blocked — TODO 67)
@@ -1,6 +1,6 @@
1
1
  # TODO PDF 67: Multi-run text batching via Canvas#text_rich
2
2
 
3
- ## Status: BLOCKED (pdfrb measure_text is a stub)
3
+ ## Status: BLOCKED (pdfrb measure_text is stub for TTF)
4
4
 
5
5
  ## Goal
6
6
 
@@ -12,32 +12,35 @@ all runs inside one BT/ET block, advancing the text matrix between runs.
12
12
 
13
13
  `text_rich` is the right primitive for multi-run text — one begin/end
14
14
  text block per frame instead of N. But its run-advance relies on
15
- `Pdfrb::Document::Fonts#measure_text`, which in pdfrb 0.4.0 returns a
16
- stub `length * 0.5 * size`. Without accurate advance, runs overwrite
17
- each other on the line.
15
+ `Pdfrb::Document::Fonts#measure_text`, which in pdfrb 0.4.0 still
16
+ returns the stub `length * 0.5 * size` for TTF/OTF fonts (only
17
+ Standard 14 AFM fonts get real measurement). Without accurate advance,
18
+ runs overwrite each other on the line.
18
19
 
19
20
  ## Blocker
20
21
 
21
- `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb:65`:
22
+ `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb`:
22
23
 
23
24
  ```ruby
24
25
  def measure_text(text, font:, size:)
25
- return 0 unless text
26
- # TODO: use font metrics for per-glyph width lookup
27
- text.to_s.length * (size || 0).to_f * 0.5
26
+ return 0 unless text && size
27
+ metrics = @afm_metrics[font]
28
+ return text.to_s.length * size.to_f * 0.5 unless metrics # STUB
29
+ ...
28
30
  end
29
31
  ```
30
32
 
31
- `glyph_width` (line 79) is also a stub returning 500, and `metrics_for`
32
- (line 87) returns nil. These need real Fontisan-backed implementations
33
- before text_rich produces correct output.
33
+ `@afm_metrics` is only populated for Standard 14 fonts. pdfrb has
34
+ the TTF parsing infrastructure (`Pdfrb::Font::TrueType::File` with
35
+ Cmap, Hmtx) but the integration with `Fonts#glyph_width` /
36
+ `measure_text` is pending.
34
37
 
35
38
  ## Plan (after pdfrb unblocks)
36
39
 
37
40
  1. Build `runs` array of `{ text:, font:, size:, color: }` per line.
38
- 2. Replace `simple_render` body with `canvas.text_lines(..., rich: true)`
39
- or `canvas.text_rich(runs, at: [x, y])`.
41
+ 2. Replace `simple_render` body with `canvas.text_rich(runs, at: [x, y])`.
40
42
  3. Verify rendered PDF: runs no longer overwrite, multi-color lines work.
43
+ 4. Combine with TODO 63 to drop Fontisan for measurement.
41
44
 
42
45
  ## Acceptance criteria
43
46
 
@@ -47,5 +50,5 @@ before text_rich produces correct output.
47
50
 
48
51
  ## Dependencies
49
52
 
50
- - pdfrb 0.4.0 `Fonts#measure_text` returns real per-glyph widths.
51
- - pdfrb 0.4.0 `Fonts#glyph_width` not returning stub 500.
53
+ - pdfrb `Fonts#measure_text` returns real per-glyph widths for TTF
54
+ fonts (currently stub see TODO 63).
@@ -0,0 +1,103 @@
1
+ # TODO PDF 75: PDF metadata enrichment from IDML XMP
2
+
3
+ ## Status: BLOCKED (Lutaml XMP namespace parsing)
4
+
5
+ ## Goal
6
+
7
+ Pull IDML document metadata from `META-INF/metadata.xml` (XMP packet)
8
+ into the PDF Info dictionary via `PdfrbWriter#set_info`:
9
+
10
+ - `dc:title` → PDF `/Title`
11
+ - `dc:creator` → PDF `/Author`
12
+ - `dc:description` → PDF `/Subject`
13
+ - `dc:subject` → PDF `/Keywords`
14
+ - `xmp:CreatorTool` → PDF `/Creator`
15
+ - `xmp:CreateDate` → PDF `/CreationDate`
16
+ - `xmp:ModifyDate` → PDF `/ModDate`
17
+
18
+ ## Blocker
19
+
20
+ `META-INF/metadata.xml` is an XMP packet — RDF/XML with multiple XML
21
+ namespaces (`dc:`, `xmp:`, `pdf:`, `xmpMM:`, `stFnt:`, etc.). Parsing
22
+ it with `lutaml-model` requires namespace-aware element mapping.
23
+
24
+ Lutaml 0.8.19's namespace API is in flux:
25
+
26
+ - `namespace "..."` with a URI string raises
27
+ `String namespace URIs are not supported. Define an XmlNamespace
28
+ class instead.`
29
+ - `namespace: Lutaml::Xml::XmlNamespace.new(uri, prefix)` raises
30
+ a `NoMethodError` inside `process_mapping`.
31
+ - `map_element "dc:format"` (prefix in name) is silently ignored —
32
+ local-name lookup strips the prefix.
33
+ - `prefix: "dc"` is ignored when there is no `namespace:` on the
34
+ mapping.
35
+
36
+ The CLAUDE.md rule "no `Lutaml::Xml::Document.parse`" rules out
37
+ falling back to a generic XmlDocument traversal. We must use a typed
38
+ model.
39
+
40
+ ## Path forward
41
+
42
+ Wait for Lutaml to stabilise its namespace API, then add a typed
43
+ `Idml::Parts::XmpMetadata` model that maps each XMP field to a Ruby
44
+ attribute. Pipeline reads `META-INF/metadata.xml`, calls
45
+ `XmpMetadata.from_xml`, and threads the resulting attributes through
46
+ `PdfrbWriter#set_info`.
47
+
48
+ When the blocker clears, the implementation looks like:
49
+
50
+ ```ruby
51
+ module Idml
52
+ module Parts
53
+ class XmpMetadata < Lutaml::Model::Serializable
54
+ attribute :title, :string
55
+ attribute :author, :string
56
+ attribute :subject, :string
57
+ attribute :keywords, :string
58
+ attribute :creator_tool, :string
59
+ attribute :create_date, :string
60
+ attribute :modify_date, :string
61
+
62
+ xml do
63
+ root "Description"
64
+ # …namespace-aware mappings once Lutaml supports them…
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ # In Pipeline:
71
+ if @package.has_part?("META-INF/metadata.xml")
72
+ xmp = Idml::Parts::XmpMetadata.from_xml(
73
+ @package.read_part("META-INF/metadata.xml"),
74
+ )
75
+ writer.set_info(
76
+ Title: xmp.title,
77
+ Author: xmp.author,
78
+ Subject: xmp.subject,
79
+ Keywords: xmp.keywords,
80
+ Creator: xmp.creator_tool,
81
+ CreationDate: pdf_date(xmp.create_date),
82
+ ModDate: pdf_date(xmp.modify_date),
83
+ )
84
+ end
85
+ ```
86
+
87
+ ## Acceptance criteria (after blocker clears)
88
+
89
+ - [ ] Pipeline reads `META-INF/metadata.xml` when present.
90
+ - [ ] `XmpMetadata` typed model parses dc:title/creator/description/
91
+ subject plus xmp:CreatorTool/CreateDate/ModifyDate.
92
+ - [ ] Pipeline passes the parsed fields to `PdfrbWriter#set_info`.
93
+ - [ ] Existing Producer/CreationDate defaults still apply when XMP
94
+ or individual fields are absent.
95
+ - [ ] Spec covers a synthetic XMP packet with all fields populated,
96
+ plus the no-metadata fallback.
97
+
98
+ ## Dependencies
99
+
100
+ - Lutaml namespace API stable enough that
101
+ `map_element "format", namespace: <XmlNamespace>` works without
102
+ raising. Tracked at the Lutaml migration guide referenced in the
103
+ error message: `docs/_guides/xml-namespaces.adoc`.
@@ -0,0 +1,92 @@
1
+ # TODO PDF 76: Tagged PDF structure tree
2
+
3
+ ## Status: PLANNED (design only)
4
+
5
+ ## Goal
6
+
7
+ Emit a tagged-PDF structure tree (PDF/UA-1, PDF 1.7 §14.8) so screen
8
+ readers and assistive technology can navigate the document. Each
9
+ rendered page item becomes a structure element (Figure for images,
10
+ P for text paragraphs, Sect for sections, Document at the root).
11
+
12
+ The CLI already has a `--tagged` flag that calls
13
+ `PdfrbWriter#enable_tagged` and `#build_structure`. Today these calls
14
+ produce an empty `/StructTreeRoot`. This TODO populates the tree.
15
+
16
+ ## Background
17
+
18
+ PDF structure elements form a tree rooted at `/StructTreeRoot`:
19
+
20
+ ```
21
+ Document
22
+ ├── Part (per spread)
23
+ │ ├── Sect (per page)
24
+ │ │ ├── Figure (per Image)
25
+ │ │ ├── P (per TextFrame paragraph)
26
+ │ │ ├── Path (per shape)
27
+ │ │ └── Sect (per Group, recursively)
28
+ ```
29
+
30
+ Each element carries:
31
+ - `/S` — structure type (Figure, P, Sect, etc.)
32
+ - `/P` — parent element reference
33
+ - `/K` — kids (mcid integers or child element refs)
34
+ - `/Pg` — page reference (for leaf elements with mcid)
35
+ - `/Alt` — alternate description (e.g. image alt text)
36
+ - `/Lang` — language override
37
+
38
+ Marked-content operators `BMC`/`EMC` (or `BDC`/`EMC` with property
39
+ list) wrap content in the content stream, carrying an MCID that
40
+ links back to the structure element.
41
+
42
+ pdfrb 0.4.0 exposes:
43
+ - `Pdfrb::Content::Canvas#tagged(tag, mcid: nil, **props, &block)` —
44
+ emits `BDC`/`EMC` with property list.
45
+ - `Pdfrb::Content::Canvas#artifact(type = nil, &block)` — wraps
46
+ content as a PDF/UA artifact (header/footer/decoration).
47
+ - `Pdfrb::Document::Structure#add_element(type, text:, alt:, page:,
48
+ mcid:)` — registers a structure element.
49
+
50
+ ## Plan
51
+
52
+ 1. **MCID allocation**: Each renderer requests an MCID from a
53
+ per-page counter (kept in `RenderContext`) before drawing its
54
+ item. The MCID goes into the `tagged` call wrapping the draw.
55
+ 2. **Structure element registration**: After the canvas draw, the
56
+ renderer calls `writer.add_structure_element(type, page_index:,
57
+ mcid:, text:, alt:)` to register the structure element.
58
+ 3. **Renderer mapping**: Each renderer maps its item to a structure
59
+ type:
60
+ - `RectangleRenderer`/`PolygonRenderer` with `ContentType="GraphicType"`
61
+ and an image child → `Figure` with `Alt` from IDML `<Image Alt="...">`.
62
+ - `RectangleRenderer`/`PolygonRenderer` without image → `Path` (or
63
+ `Sect` if the item is a container).
64
+ - `TextFrameRenderer` → `P` (one per paragraph run).
65
+ - `GroupRenderer` → `Sect` (wraps its children's elements).
66
+ - `TableRenderer` → `Table`, `TR`, `TH`, `TD` per row/cell.
67
+ 4. **Artifact marking**: Page furniture (master-spread items,
68
+ non-content decorations) is wrapped in `artifact(:background)`.
69
+ 5. **Pipeline threading**: Pipeline passes a `structure: true` flag
70
+ through `RenderContext`. Renderers only emit structure when the
71
+ flag is set.
72
+ 6. **Build**: Pipeline calls `writer.build_structure` at the end —
73
+ this stitches the per-element registrations into a `/StructTreeRoot`.
74
+
75
+ ## Acceptance criteria
76
+
77
+ - [ ] `idml render --tagged sample.idml -o out.pdf` produces a PDF
78
+ whose `/StructTreeRoot` has a non-empty `/K` array.
79
+ - [ ] Each visible page item maps to a structure element with the
80
+ right type (Figure, P, Path, Sect, Table).
81
+ - [ ] `pdfinfo out.pdf` (or equivalent) reports `Tagged: yes`.
82
+ - [ ] Spec renders a fixture and asserts presence of structure
83
+ elements matching the rendered items.
84
+ - [ ] Items filtered out by `LayerFilter` do not get structure
85
+ entries.
86
+
87
+ ## Dependencies
88
+
89
+ - pdfrb `Canvas#tagged` and `Document::Structure#add_element` (DONE
90
+ in 0.4.0).
91
+ - Per-page MCID counter — small addition to `RenderContext`.
92
+ - Per-renderer structure-type mapping table.
@@ -0,0 +1,74 @@
1
+ # TODO PDF 77: PDF/A XMP metadata and output intent
2
+
3
+ ## Status: PLANNED (design only)
4
+
5
+ ## Goal
6
+
7
+ Emit PDF/A-compliant XMP metadata so the rendered PDF passes
8
+ veraPDF/A validation. PDF/A requires:
9
+
10
+ 1. An XMP metadata stream on the Catalog (`/Metadata`).
11
+ 2. The XMP must include:
12
+ - `pdfaid:part` — PDF/A version part number (e.g. `2`).
13
+ - `pdfaid:conformance` — `A`, `B`, or `U`.
14
+ - `dc:format` = `application/pdf`.
15
+ 3. An `/OutputIntent` referencing an ICC profile (sRGB for PDF/A-2).
16
+ 4. No unreferenced fonts, embedded fonts subsetted, no JPEG-in-JPEG,
17
+ etc. (TODOs 52, 53 cover most of this).
18
+
19
+ The CLI's `--pdf-a` flag already sets `compliance: :pdfa2a` on the
20
+ Pipeline. Today this is treated as a hint but produces no PDF/A-
21
+ specific output. This TODO implements the actual XMP + output
22
+ intent emission.
23
+
24
+ ## Background
25
+
26
+ pdfrb 0.4.0 ships the primitives:
27
+
28
+ - `Pdfrb::XMP::Packet` — assembles an XMP packet from Dublin Core,
29
+ PDF, XMP Basic, and XMP Rights schemas.
30
+ - `Pdfrb::Document::OutputIntents#embed_icc(icc_bytes, identifier:,
31
+ condition:)` — embeds an ICC profile and adds an `/OutputIntent`.
32
+ - `Pdfrb::Document::OutputIntents#add(ref, identifier:, condition:)` —
33
+ adds an output intent referencing an existing stream.
34
+
35
+ pdfrb's `XMP::Schemas` only covers Dublin Core + PDF + XMP Basic +
36
+ XMP Rights. The PDF/A `pdfaid:` namespace is not yet modelled, so
37
+ extending the packet requires either:
38
+
39
+ 1. Subclassing `Pdfrb::XMP::Packet` to add a `pdfaid` schema (clean,
40
+ preserves pdfrb's serialisation).
41
+ 2. Building the packet string by hand (rejected — hand-rolled
42
+ serialisation violates the project's lutaml-model-only rule).
43
+
44
+ ## Plan
45
+
46
+ 1. Define a `PdfaidNS` Lutaml namespace class and a `Pdfaid` schema
47
+ with `part` and `conformance` attributes.
48
+ 2. Extend `Pdfrb::XMP::Packet` (or contribute upstream) to include
49
+ the pdfaid schema in the packet body.
50
+ 3. Bundle an sRGB ICC profile (or accept a user-supplied path).
51
+ 4. New `Idml::Render::PdfaCompliance` helper called from Pipeline
52
+ when `compliance:` is set:
53
+ - Embed sRGB ICC via `writer.document.output_intents.embed_icc(...)`.
54
+ - Build the XMP packet with pdfaid:part=2, pdfaid:conformance=A.
55
+ - Attach the packet to the Catalog as `/Metadata`.
56
+ 5. Spec the XMP packet bytes contain `pdfaid:part` and the catalog
57
+ carries `/OutputIntents`.
58
+
59
+ ## Acceptance criteria
60
+
61
+ - [ ] `idml render --pdf-a sample.idml -o out.pdf` produces a PDF
62
+ with `/OutputIntents` referencing an sRGB ICC profile.
63
+ - [ ] The PDF's `/Metadata` stream contains `pdfaid:part` and
64
+ `pdfaid:conformance` elements.
65
+ - [ ] `veraPDF --flavour 2a out.pdf` reports compliance (or, if
66
+ other rules fail, lists only non-metadata failures).
67
+ - [ ] Spec covers XMP assembly and ICC embedding.
68
+
69
+ ## Dependencies
70
+
71
+ - pdfrb 0.4.0 `XMP::Packet`, `OutputIntents` (DONE).
72
+ - A pdfaid schema model in pdfrb or in `Idml::Render::XmpExtensions`.
73
+ - An sRGB ICC profile asset.
74
+ - TODO 75 (Lutaml XMP) — same blocker.
data/lib/idml/cli.rb CHANGED
@@ -67,6 +67,8 @@ module Idml
67
67
  desc: "Produce PDF/A-2a compliant output"
68
68
  method_option :tagged, type: :boolean, default: false,
69
69
  desc: "Produce tagged PDF (PDF/UA)"
70
+ method_option :no_subset, type: :boolean, default: false,
71
+ desc: "Skip font subsetting (larger PDF)"
70
72
  method_option :verbose, aliases: "-v", type: :boolean, default: false,
71
73
  desc: "Print progress"
72
74
  def render(path)
@@ -97,6 +99,7 @@ module Idml
97
99
  font_search_paths: font_search_paths,
98
100
  compliance: options[:pdf_a] ? :pdfa2a : nil,
99
101
  tagged: options[:tagged],
102
+ subset_fonts: !options[:no_subset],
100
103
  }
101
104
  end
102
105
 
@@ -6,14 +6,26 @@ module Idml
6
6
  # Delegates all PDF assembly to pdfrb (no hand-rolled PDF code).
7
7
  # The adapter provides a consistent interface for the Pipeline
8
8
  # (add_page → returns Canvas, add_image → name, register_font → name,
9
- # set_info, add_bookmark) while letting pdfrb handle xref, trailer,
10
- # object streams, and operator emission.
9
+ # set_info, add_bookmark, subset_fonts!) while letting pdfrb handle
10
+ # xref, trailer, object streams, and operator emission.
11
11
  class PdfrbWriter
12
12
  DEFAULT_WIDTH = 612
13
13
  DEFAULT_HEIGHT = 792
14
14
 
15
+ META_SETTERS = {
16
+ Title: :title=,
17
+ Author: :author=,
18
+ Subject: :subject=,
19
+ Keywords: :keywords=,
20
+ Creator: :creator=,
21
+ Producer: :producer=,
22
+ CreationDate: :creationdate=,
23
+ ModDate: :moddate=,
24
+ }.freeze
25
+
15
26
  def initialize
16
27
  @document = Pdfrb::Document.new
28
+ @image_cache = {}
17
29
  end
18
30
 
19
31
  def add_page(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
@@ -39,20 +51,25 @@ module Idml
39
51
  meta = @document.metadata
40
52
  hash.each do |key, value|
41
53
  setter = META_SETTERS[key.to_sym]
42
- meta.public_send(setter, value.to_s) if setter
54
+ next unless setter
55
+
56
+ meta.public_send(setter, value.to_s)
43
57
  end
44
58
  end
45
59
 
60
+ def subset_fonts!
61
+ @document.fonts.subset_fonts!
62
+ end
63
+
46
64
  def write(path)
47
65
  @document.write(path)
48
66
  end
49
67
 
50
68
  def image_name_for(uri)
51
- @image_cache&.key?(uri) ? @image_cache[uri] : nil
69
+ @image_cache[uri]
52
70
  end
53
71
 
54
72
  def register_image_name(uri, name)
55
- @image_cache ||= {}
56
73
  @image_cache[uri] = name
57
74
  end
58
75
 
@@ -72,17 +89,6 @@ module Idml
72
89
  def document
73
90
  @document
74
91
  end
75
-
76
- META_SETTERS = {
77
- Title: :title=,
78
- Author: :author=,
79
- Subject: :subject=,
80
- Keywords: :keywords=,
81
- Creator: :creator=,
82
- Producer: :producer=,
83
- CreationDate: :creationdate=,
84
- ModDate: :moddate=,
85
- }.freeze
86
92
  end
87
93
  end
88
94
  end
@@ -10,12 +10,13 @@ module Idml
10
10
  DEFAULT_HEIGHT = 792
11
11
 
12
12
  def initialize(package, output_path, font_search_paths = nil,
13
- compliance: nil, tagged: false)
13
+ compliance: nil, tagged: false, subset_fonts: true)
14
14
  @package = package
15
15
  @output_path = output_path
16
16
  @font_resolver = build_font_resolver(font_search_paths)
17
17
  @compliance = compliance
18
18
  @tagged = tagged
19
+ @subset_fonts = subset_fonts
19
20
  end
20
21
 
21
22
  def call
@@ -33,6 +34,7 @@ module Idml
33
34
  end
34
35
 
35
36
  writer.build_structure if @tagged
37
+ writer.subset_fonts! if @subset_fonts
36
38
  writer.write(@output_path)
37
39
  @output_path
38
40
  end
data/lib/idml/render.rb CHANGED
@@ -25,11 +25,16 @@ module Idml
25
25
 
26
26
  DEFAULT_FONT = "Helvetica"
27
27
 
28
+ # Render an IDML package to a PDF file. All options except
29
+ # `package:` and `to:` are keyword-only and optional.
30
+ # rubocop:disable Metrics/ParameterLists
28
31
  def self.render(package:, to:, font_search_paths: nil, compliance: nil,
29
- tagged: false)
30
- Pipeline.new(package, to, font_search_paths, compliance: compliance,
31
- tagged: tagged).call
32
+ tagged: false, subset_fonts: true)
33
+ Pipeline.new(package, to, font_search_paths,
34
+ compliance: compliance, tagged: tagged,
35
+ subset_fonts: subset_fonts).call
32
36
  end
37
+ # rubocop:enable Metrics/ParameterLists
33
38
  end
34
39
  end
35
40
 
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.2.7"
4
+ VERSION = "0.2.8"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -215,6 +215,9 @@ files:
215
215
  - TODO.pdf/72-stroke-styling.md
216
216
  - TODO.pdf/73-gradient-resolver-cleanup.md
217
217
  - TODO.pdf/74-renderer-spec-coverage.md
218
+ - TODO.pdf/75-pdf-metadata-from-xmp.md
219
+ - TODO.pdf/76-tagged-pdf-structure.md
220
+ - TODO.pdf/77-pdfa-xmp-output-intent.md
218
221
  - TODO.pdf/README.md
219
222
  - exe/idml
220
223
  - idml.gemspec