idml 0.3.0 → 0.3.1
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 +4 -4
- data/Gemfile.lock +2 -18
- data/TODO.pdf/63-replace-fontmetrics-with-pdfrb.md +59 -58
- data/TODO.pdf/65-pdfrb-019-integration.md +57 -29
- data/TODO.pdf/67-text-rich-multi-run.md +35 -39
- data/idml.gemspec +0 -1
- data/lib/idml/render/pipeline.rb +27 -17
- data/lib/idml/render/render_context.rb +1 -1
- data/lib/idml/render/renderers/group_renderer.rb +3 -1
- data/lib/idml/render/renderers/text_frame_renderer.rb +34 -25
- data/lib/idml/render/spread_renderer.rb +3 -3
- data/lib/idml/text_engine/pdfrb_font_metrics.rb +81 -0
- data/lib/idml/text_engine.rb +1 -2
- data/lib/idml/version.rb +1 -1
- metadata +2 -17
- data/lib/idml/text_engine/font_metrics.rb +0 -226
- data/lib/idml/text_engine/font_resolver.rb +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a99eee06508972bf25be9b3c086af445dd8fab275b8c83e9e588e6fc15c5ce2c
|
|
4
|
+
data.tar.gz: 4fc3c99e4dd7be9bfd2c00ab90bf3684bfc397fb838f4504cde8eed575e7ff65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef32e1d3a418e26d020638aeb955a4296a5f2574c7ae2d75bc105c345b170bcf4497c3f8fc4e7201dbf159302fcb9c4f2881d6da26355e4f1027973f88ed5140
|
|
7
|
+
data.tar.gz: fb60042f7b9c4fec7cb619a7c2d747daf64229136997cd35d043ee02546facd2003f4cf6a9d52f6444549b4fa7a700371732566722dbd84a6cdac7cb10993a2b
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
idml (0.3.
|
|
4
|
+
idml (0.3.1)
|
|
5
5
|
bigdecimal
|
|
6
|
-
fontisan
|
|
7
6
|
lutaml-model (~> 0.8.18)
|
|
8
7
|
pdfrb
|
|
9
8
|
rubyzip
|
|
@@ -21,8 +20,6 @@ GEM
|
|
|
21
20
|
ast (2.4.3)
|
|
22
21
|
base64 (0.3.0)
|
|
23
22
|
bigdecimal (4.1.2)
|
|
24
|
-
bindata (2.5.1)
|
|
25
|
-
brotli (0.8.0)
|
|
26
23
|
canon (0.2.12)
|
|
27
24
|
diff-lcs
|
|
28
25
|
json
|
|
@@ -44,21 +41,12 @@ GEM
|
|
|
44
41
|
ffi (1.17.4-x86_64-darwin)
|
|
45
42
|
ffi (1.17.4-x86_64-linux-gnu)
|
|
46
43
|
ffi (1.17.4-x86_64-linux-musl)
|
|
47
|
-
fontisan (0.4.45)
|
|
48
|
-
base64
|
|
49
|
-
bindata (~> 2.5)
|
|
50
|
-
brotli (~> 0.5)
|
|
51
|
-
logger
|
|
52
|
-
lutaml-model (~> 0.8)
|
|
53
|
-
nokogiri (~> 1.16)
|
|
54
|
-
thor (~> 1.3)
|
|
55
44
|
json (2.21.2)
|
|
56
45
|
language_server-protocol (3.17.0.6)
|
|
57
46
|
lint_roller (1.1.0)
|
|
58
47
|
liquid (5.13.0)
|
|
59
48
|
bigdecimal
|
|
60
49
|
strscan (>= 3.1.1)
|
|
61
|
-
logger (1.7.0)
|
|
62
50
|
lutaml-model (0.8.19)
|
|
63
51
|
base64
|
|
64
52
|
bigdecimal
|
|
@@ -183,8 +171,6 @@ CHECKSUMS
|
|
|
183
171
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
184
172
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
185
173
|
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
|
186
|
-
bindata (2.5.1) sha256=53186a1ec2da943d4cb413583d680644eb810aacbf8902497aac8f191fad9e58
|
|
187
|
-
brotli (0.8.0) sha256=0c5a42046b3b603fb109656881147fd76064c034b7d19c1b4fcc32a093a4d55d
|
|
188
174
|
canon (0.2.12) sha256=9b87514fbf6c0f9f28d1e2746ea5837b08e24e50c30751dba27a89f86e8709af
|
|
189
175
|
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
|
|
190
176
|
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
|
@@ -197,13 +183,11 @@ CHECKSUMS
|
|
|
197
183
|
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
|
|
198
184
|
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
|
|
199
185
|
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
|
|
200
|
-
|
|
201
|
-
idml (0.3.0)
|
|
186
|
+
idml (0.3.1)
|
|
202
187
|
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
|
203
188
|
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
204
189
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
205
190
|
liquid (5.13.0) sha256=09d594fd96b98e19a8fd5c32aadcc6950df429b69f48c2f774c0db0ce272bf19
|
|
206
|
-
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
207
191
|
lutaml-model (0.8.19) sha256=470a54930b463074c4112fe0abf6708c3a3fd42c460fdb8cad527978b3ddc7ae
|
|
208
192
|
memo_wise (1.13.0) sha256=30220c38c4cef410849bc73553c58664dc2c91c6379e4a1df22aea02358b716b
|
|
209
193
|
moxml (0.1.26) sha256=f29c2a76377f014920fbb9bec8d66acc3f162fa8a4f6a855010c3d2ac1ceeb70
|
|
@@ -1,60 +1,61 @@
|
|
|
1
1
|
# TODO PDF 63: Replace FontMetrics with pdfrb measurement API
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
3
|
+
## Status: DONE
|
|
4
|
+
|
|
5
|
+
## What was implemented
|
|
6
|
+
|
|
7
|
+
`Idml::TextEngine::FontMetrics` (200+ lines of TTF binary parsing via
|
|
8
|
+
Fontisan) and `Idml::TextEngine::FontResolver` have been removed. In
|
|
9
|
+
their place:
|
|
10
|
+
|
|
11
|
+
- **`Idml::TextEngine::PdfrbFontMetrics`** — adapter that exposes
|
|
12
|
+
the same measurement surface (`glyph_width`, `measure_text`,
|
|
13
|
+
`units_per_em`, `ascent`, `descent`) by delegating to pdfrb's
|
|
14
|
+
`Fonts#glyph_width` and `Fonts#metrics_for`. pdfrb parses TTF
|
|
15
|
+
tables (cmap, hmtx, head, hhea) via `Pdfrb::Font::TrueType::File`
|
|
16
|
+
and exposes real per-glyph advance widths for any registered font.
|
|
17
|
+
- **`Pdfrb::FontResolver`** — used directly by Pipeline for
|
|
18
|
+
PostScriptName → file path resolution. Returns paths as strings,
|
|
19
|
+
no FontMetrics wrapper.
|
|
20
|
+
|
|
21
|
+
## What was removed
|
|
22
|
+
|
|
23
|
+
- `lib/idml/text_engine/font_metrics.rb` (200+ lines of Fontisan parsing).
|
|
24
|
+
- `lib/idml/text_engine/font_resolver.rb` (Fontisan-coupled resolver).
|
|
25
|
+
- `fontisan` gemspec dependency.
|
|
26
|
+
- `fontisan` from `spec/anti_patterns_spec.rb` external require allowlist.
|
|
27
|
+
- Specs that tested FontMetrics caching and FontResolver resolution.
|
|
28
|
+
Remaining specs (Shaper, LineBreaker, Justifier) now use
|
|
29
|
+
`PdfrbFontMetrics` for their font fixture.
|
|
30
|
+
|
|
31
|
+
## Pipeline integration
|
|
32
|
+
|
|
33
|
+
1. `Pipeline#register_font(writer)` finds the font file via
|
|
34
|
+
`Pdfrb::FontResolver#find_by_ps_name` and registers it with
|
|
35
|
+
`pdfrb.fonts.add(path)`, returning a Symbol resource.
|
|
36
|
+
2. `Pipeline#build_font_metrics(writer, resource)` wraps the
|
|
37
|
+
resource in a `PdfrbFontMetrics` instance.
|
|
38
|
+
3. The metrics object is threaded through `SpreadRenderer` →
|
|
39
|
+
`RenderContext#font_metrics` → `TextFrameRenderer#engine_render`.
|
|
40
|
+
4. Shaper and LineBreaker consume `font.units_per_em` and
|
|
41
|
+
`font.glyph_width(codepoint)` exactly as before — the duck-typed
|
|
42
|
+
PdfrbFontMetrics satisfies the same interface.
|
|
43
|
+
|
|
44
|
+
## Verification
|
|
45
|
+
|
|
46
|
+
- `lib/idml/text_engine/pdfrb_font_metrics.rb` — adapter.
|
|
47
|
+
- `lib/idml/render/pipeline.rb` — font_resolver + font_metrics wiring.
|
|
48
|
+
- `spec/idml/text_engine/pdfrb_font_metrics_spec.rb` — 13 specs.
|
|
49
|
+
- `spec/idml/text_engine/text_engine_spec.rb` — Shaper/LineBreaker/
|
|
50
|
+
Justifier specs use pdfrb metrics.
|
|
51
|
+
- All `bundle exec rake` green (2500+ examples).
|
|
52
|
+
- Anti-pattern spec green with `fontisan` removed from allowlist.
|
|
53
|
+
|
|
54
|
+
## Acceptance criteria
|
|
55
|
+
|
|
56
|
+
- [x] Pipeline registers fonts via `pdfrb.fonts.add(path)`.
|
|
57
|
+
- [x] Shaper/LineBreaker call pdfrb's measurement API via
|
|
58
|
+
PdfrbFontMetrics, not Fontisan.
|
|
59
|
+
- [x] `fontisan` removed from gemspec dependencies.
|
|
60
|
+
- [x] `FontMetrics` and `FontResolver` deleted from `lib/`.
|
|
61
|
+
- [x] Specs use PdfrbFontMetrics fixtures, not Fontisan.
|
|
@@ -1,39 +1,67 @@
|
|
|
1
1
|
# TODO PDF 65: pdfrb feature integration
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
All proposals landed in pdfrb 0.4.0+ are now integrated into the idml
|
|
6
|
+
gem's render pipeline. Summary of what's wired up:
|
|
6
7
|
|
|
7
|
-
|
|
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).
|
|
8
|
+
## Implemented features
|
|
19
9
|
|
|
20
|
-
|
|
10
|
+
1. **Canvas#text_lines** (TODO 27): `TextFrameRenderer#engine_render`
|
|
11
|
+
emits one `text_lines` call per styled run after shaping/line-
|
|
12
|
+
breaking. Each run's glyphs render as a batched multi-line block.
|
|
13
|
+
2. **Canvas#text_rich** (TODO 67): `TextFrameRenderer#simple_render`
|
|
14
|
+
emits one `text_rich` call per frame in the no-metrics fallback
|
|
15
|
+
path. pdfrb's measurement API handles run advance.
|
|
16
|
+
3. **Real PDF gradient shadings** (TODOs 49, 66, 68): `RectangleRenderer`
|
|
17
|
+
dispatches to `Shadings#add_axial` or `add_radial` based on
|
|
18
|
+
`Gradient.type`, then `Canvas#fill_shading`.
|
|
19
|
+
4. **Canvas#with_transparency** (TODO 69): `Blending.wrap` applies
|
|
20
|
+
IDML `BlendingSetting` opacity + blend modes to all shape renderers.
|
|
21
|
+
5. **Stroke-style setters** (TODO 70/72): `StrokeStyle.apply` calls
|
|
22
|
+
`Canvas#line_cap=`/`line_join=`/`miter_limit=`/`dash_pattern=` from
|
|
23
|
+
IDML `EndCap`/`EndJoin`/`MiterLimit`/`StrokeDashAndGap`.
|
|
24
|
+
6. **Font subsetting** (TODO 52): `Pipeline` calls `Fonts#subset_fonts!`
|
|
25
|
+
before write. Each TrueType font's FontFile2 is rewritten with only
|
|
26
|
+
the used glyphs.
|
|
27
|
+
7. **Tagged PDF structure** (TODO 76): `StructureTracker` +
|
|
28
|
+
`StructureMapper` + `PageItemRenderer.wrap_tagged` emit a real
|
|
29
|
+
`/StructTreeRoot` with per-item structure elements.
|
|
30
|
+
8. **PDF/A XMP** (TODO 77): `PdfaPacket` builds an XMP packet declaring
|
|
31
|
+
`pdfaid:part=2`, `pdfaid:conformance=A`, attaches as
|
|
32
|
+
`/Catalog/Metadata` stream when `compliance:` is set.
|
|
33
|
+
9. **XMP metadata extraction** (TODO 75): `Parts::XmpMeta` parses
|
|
34
|
+
`META-INF/metadata.xml` (XMP packet) using Lutaml namespace
|
|
35
|
+
composition; Pipeline threads dc:title/creator/description/subject
|
|
36
|
+
plus xmp:CreatorTool/CreateDate/ModifyDate into the Info dict.
|
|
37
|
+
10. **Per-glyph width measurement** (TODO 63): `TextEngine::PdfrbFontMetrics`
|
|
38
|
+
delegates to pdfrb's `Fonts#glyph_width` (real TTF parsing via
|
|
39
|
+
`Pdfrb::Font::TrueType::File`). Fontisan dependency removed.
|
|
40
|
+
11. **Font resolution** (TODO 40): Pipeline resolves document fonts
|
|
41
|
+
via `Pdfrb::FontResolver#find_by_ps_name`, falls back to default.
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
## Deferred / out of scope
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
- **PDF/A ICC output intent** (TODO 77 partial) — sRGB ICC profile
|
|
46
|
+
not vendored. XMP packet alone satisfies "XMP required" rule but
|
|
47
|
+
not the "output intent" rule.
|
|
48
|
+
- **Hyperlinks** (TODO 78) — design doc only.
|
|
49
|
+
- **Bookmarks/outline** (TODO 79) — design doc only.
|
|
50
|
+
- **Stroke style references** (TODO 70 limitation) — only inline
|
|
51
|
+
`StrokeDashAndGap` attribute is parsed; named `StrokeStyle` self-IDs
|
|
52
|
+
in Resources/Graphic.xml not yet resolved.
|
|
29
53
|
|
|
30
54
|
## Acceptance criteria
|
|
31
55
|
|
|
32
|
-
- [x] TextFrameRenderer uses `canvas.text_lines`
|
|
33
|
-
|
|
34
|
-
- [x]
|
|
35
|
-
- [x]
|
|
36
|
-
- [x]
|
|
37
|
-
- [x]
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
56
|
+
- [x] TextFrameRenderer uses `canvas.text_lines` (engine path) and
|
|
57
|
+
`canvas.text_rich` (fallback path).
|
|
58
|
+
- [x] Real PDF gradient shadings via pdfrb.
|
|
59
|
+
- [x] Transparency and blend modes.
|
|
60
|
+
- [x] Stroke styling.
|
|
61
|
+
- [x] Font subsetting.
|
|
62
|
+
- [x] Tagged PDF structure.
|
|
63
|
+
- [x] PDF/A XMP packet.
|
|
64
|
+
- [x] XMP metadata extraction.
|
|
65
|
+
- [x] FontMetrics replaced with pdfrb measurement.
|
|
66
|
+
- [x] FontResolver replaced with `Pdfrb::FontResolver`.
|
|
67
|
+
- [x] `fontisan` removed from gemspec.
|
|
@@ -1,54 +1,50 @@
|
|
|
1
1
|
# TODO PDF 67: Multi-run text batching via Canvas#text_rich
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What was implemented
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
`TextFrameRenderer#simple_render` (the fallback path when no
|
|
8
|
+
`FontMetrics` is available) now emits a single `Canvas#text_rich`
|
|
9
|
+
call per text frame, batched across all runs. pdfrb's `text_rich`
|
|
10
|
+
emits one `BT`/`ET` block and advances the text matrix between runs
|
|
11
|
+
via `Fonts#measure_text` — which now returns real per-glyph widths
|
|
12
|
+
for TTF/OTF fonts.
|
|
10
13
|
|
|
11
|
-
##
|
|
14
|
+
## Architecture
|
|
12
15
|
|
|
13
|
-
`
|
|
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 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.
|
|
16
|
+
`TextFrameRenderer#render_text` chooses between two paths:
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
- **`engine_render`** — used when `context.font_metrics` is present.
|
|
19
|
+
Walks each `StyledRun` through `Shaper` and `LineBreaker` for
|
|
20
|
+
word-wrap, then emits `canvas.text_lines` per run.
|
|
21
|
+
- **`simple_render`** — fallback when metrics are absent. Builds a
|
|
22
|
+
`{ text:, font:, size: }` array and emits one `canvas.text_rich`
|
|
23
|
+
call. pdfrb's measurement handles run advance.
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
Both paths use the same `frame_box` geometry (via `Placement.box`).
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
def measure_text(text, font:, size:)
|
|
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
|
-
...
|
|
30
|
-
end
|
|
31
|
-
```
|
|
27
|
+
## Why two paths
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
the
|
|
35
|
-
|
|
36
|
-
`
|
|
29
|
+
The engine path performs IDML-faithful word-wrap and line breaking
|
|
30
|
+
using the text engine (Shaper/LineBreaker). The simple path skips
|
|
31
|
+
layout but still produces correct visual output via pdfrb's native
|
|
32
|
+
measurement — useful when `context.font_metrics` is nil (e.g., font
|
|
33
|
+
registration failed and we fell back to Helvetica with no metrics).
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
As pdfrb's measurement API stabilises, a future refactor could merge
|
|
36
|
+
the two paths and let pdfrb handle both layout and emission.
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
2. Replace `simple_render` body with `canvas.text_rich(runs, at: [x, y])`.
|
|
42
|
-
3. Verify rendered PDF: runs no longer overwrite, multi-color lines work.
|
|
43
|
-
4. Combine with TODO 63 to drop Fontisan for measurement.
|
|
38
|
+
## Verification
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
40
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb:97` — `simple_render`.
|
|
41
|
+
- `spec/idml/render/render_pdfrb_pipeline_spec.rb` — integration
|
|
42
|
+
spec verifies BT/ET emission.
|
|
43
|
+
- `spec/idml/render/text_frame_renderer_spec.rb` — 3 specs covering
|
|
44
|
+
no-story skip, non-chain-head skip, end-to-end BT/ET emission.
|
|
50
45
|
|
|
51
|
-
##
|
|
46
|
+
## Acceptance criteria
|
|
52
47
|
|
|
53
|
-
-
|
|
54
|
-
|
|
48
|
+
- [x] `TextFrameRenderer#simple_render` uses `canvas.text_rich`.
|
|
49
|
+
- [x] Single BT/ET block per frame in fallback path.
|
|
50
|
+
- [x] Render spec verifies text emission end-to-end.
|
data/idml.gemspec
CHANGED
data/lib/idml/render/pipeline.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Idml
|
|
|
13
13
|
compliance: nil, tagged: false, subset_fonts: true)
|
|
14
14
|
@package = package
|
|
15
15
|
@output_path = output_path
|
|
16
|
-
@
|
|
16
|
+
@font_search_paths = font_search_paths
|
|
17
17
|
@compliance = compliance
|
|
18
18
|
@tagged = tagged
|
|
19
19
|
@subset_fonts = subset_fonts
|
|
@@ -29,13 +29,15 @@ module Idml
|
|
|
29
29
|
layer_filter = LayerFilter.from_designmap(@package.designmap)
|
|
30
30
|
font_ref_resolver = FontReferenceResolver.build(@package)
|
|
31
31
|
base_dir = File.dirname(@package.path)
|
|
32
|
-
|
|
32
|
+
font_resource = register_font(writer)
|
|
33
|
+
font_metrics = build_font_metrics(writer, font_resource)
|
|
33
34
|
page_index = -1
|
|
34
35
|
|
|
35
36
|
@package.spreads.each do |spread|
|
|
36
37
|
page_index = render_spread_pages(writer, spread, base_dir,
|
|
37
38
|
layer_filter, font_ref_resolver,
|
|
38
|
-
|
|
39
|
+
font_resource, font_metrics,
|
|
40
|
+
structure, page_index)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
structure.flush(writer)
|
|
@@ -52,13 +54,13 @@ module Idml
|
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def render_spread_pages(writer, spread, base_dir, layer_filter,
|
|
55
|
-
font_ref_resolver,
|
|
56
|
-
page_offset)
|
|
57
|
+
font_ref_resolver, font_resource, font_metrics,
|
|
58
|
+
structure, page_offset)
|
|
57
59
|
pages = spread.spread.flat_map(&:page)
|
|
58
60
|
image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
|
|
59
61
|
page_height: DEFAULT_HEIGHT).collect(spread)
|
|
60
|
-
renderer = build_renderer(layer_filter, font_ref_resolver,
|
|
61
|
-
structure: structure)
|
|
62
|
+
renderer = build_renderer(layer_filter, font_ref_resolver,
|
|
63
|
+
font_resource, font_metrics, structure: structure)
|
|
62
64
|
current = page_offset
|
|
63
65
|
|
|
64
66
|
pages.each do |page|
|
|
@@ -73,10 +75,11 @@ module Idml
|
|
|
73
75
|
current
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
def build_renderer(layer_filter, font_ref_resolver,
|
|
78
|
+
def build_renderer(layer_filter, font_ref_resolver, font_resource,
|
|
79
|
+
font_metrics, structure:)
|
|
77
80
|
SpreadRenderer.new(
|
|
78
|
-
|
|
79
|
-
font_ps_name:
|
|
81
|
+
font_metrics: font_metrics,
|
|
82
|
+
font_ps_name: font_resource,
|
|
80
83
|
package: @package,
|
|
81
84
|
layer_filter: layer_filter,
|
|
82
85
|
font_ref_resolver: font_ref_resolver,
|
|
@@ -89,14 +92,15 @@ module Idml
|
|
|
89
92
|
height: page.height || DEFAULT_HEIGHT }
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
def build_font_metrics(writer, font_resource)
|
|
96
|
+
return nil if font_resource == Render::DEFAULT_FONT
|
|
97
|
+
|
|
98
|
+
TextEngine::PdfrbFontMetrics.new(writer.document.fonts, font_resource)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
95
101
|
end
|
|
96
102
|
|
|
97
103
|
def register_font(writer)
|
|
98
|
-
return Render::DEFAULT_FONT unless @font_resolver
|
|
99
|
-
|
|
100
104
|
path = resolve_document_font_path
|
|
101
105
|
return Render::DEFAULT_FONT unless path
|
|
102
106
|
|
|
@@ -105,6 +109,12 @@ module Idml
|
|
|
105
109
|
Render::DEFAULT_FONT
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
def font_resolver
|
|
113
|
+
@font_resolver ||= Pdfrb::FontResolver.new(
|
|
114
|
+
search_paths: @font_search_paths || Pdfrb::FontResolver::DEFAULT_SEARCH_PATHS,
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
108
118
|
def resolve_document_font_path
|
|
109
119
|
return nil unless @package&.fonts
|
|
110
120
|
|
|
@@ -120,8 +130,8 @@ module Idml
|
|
|
120
130
|
next unless font.post_script_name
|
|
121
131
|
next if font.status == "Missing"
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
return
|
|
133
|
+
path = font_resolver.find_by_ps_name(font.post_script_name)
|
|
134
|
+
return path if path
|
|
125
135
|
end
|
|
126
136
|
nil
|
|
127
137
|
end
|
|
@@ -14,13 +14,15 @@ module Idml
|
|
|
14
14
|
child_context = Render::RenderContext.new(
|
|
15
15
|
item: child,
|
|
16
16
|
package: context.package,
|
|
17
|
-
|
|
17
|
+
font_metrics: context.font_metrics,
|
|
18
18
|
font_ref_resolver: context.font_ref_resolver,
|
|
19
19
|
color_resolver: context.color_resolver,
|
|
20
20
|
font_ps_name: context.font_ps_name,
|
|
21
21
|
page_width: context.page_width,
|
|
22
22
|
page_height: context.page_height,
|
|
23
23
|
layer_filter: context.layer_filter,
|
|
24
|
+
structure: context.structure,
|
|
25
|
+
page_index: context.page_index,
|
|
24
26
|
)
|
|
25
27
|
PageItemRenderer.render(canvas, child_context)
|
|
26
28
|
end
|
|
@@ -4,10 +4,10 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
6
|
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas. Extracts
|
|
7
|
-
# styled runs via StyleResolver. When a FontMetrics is available
|
|
8
|
-
# runs the full text engine pipeline (Shaper →
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# styled runs via StyleResolver. When a FontMetrics is available
|
|
8
|
+
# (pdfrb-backed), runs the full text engine pipeline (Shaper →
|
|
9
|
+
# LineBreaker) for proper word-wrap; otherwise falls back to a
|
|
10
|
+
# single text_rich call per frame.
|
|
11
11
|
class TextFrameRenderer
|
|
12
12
|
DEFAULT_SIZE = 12.0
|
|
13
13
|
LEADING_FACTOR = 1.2
|
|
@@ -28,7 +28,7 @@ module Idml
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def self.render_text(canvas, runs, context, box)
|
|
31
|
-
font =
|
|
31
|
+
font = context.font_metrics
|
|
32
32
|
if font
|
|
33
33
|
engine_render(canvas, runs, context, box, font)
|
|
34
34
|
else
|
|
@@ -47,26 +47,6 @@ module Idml
|
|
|
47
47
|
end
|
|
48
48
|
private_class_method :frame_box
|
|
49
49
|
|
|
50
|
-
def self.resolve_font_metrics(context)
|
|
51
|
-
return nil unless context.font_resolver
|
|
52
|
-
|
|
53
|
-
context.font_resolver.resolve(
|
|
54
|
-
family_name: Render::DEFAULT_FONT, style_name: "Regular",
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
private_class_method :resolve_font_metrics
|
|
58
|
-
|
|
59
|
-
def self.simple_render(canvas, runs, context, box)
|
|
60
|
-
runs.each_with_index do |run, index|
|
|
61
|
-
y = box[:y] + box[:height] -
|
|
62
|
-
((index + 1) * run.point_size * LEADING_FACTOR)
|
|
63
|
-
canvas.text(run.text, at: [box[:x], y],
|
|
64
|
-
font: context.font_ps_name,
|
|
65
|
-
size: run.point_size)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
private_class_method :simple_render
|
|
69
|
-
|
|
70
50
|
def self.engine_render(canvas, runs, context, box, font)
|
|
71
51
|
baseline_y = box[:y] + box[:height] - runs.first.point_size
|
|
72
52
|
|
|
@@ -78,6 +58,9 @@ module Idml
|
|
|
78
58
|
end
|
|
79
59
|
private_class_method :engine_render
|
|
80
60
|
|
|
61
|
+
# Emits one `canvas.text_lines` call per run, after shaping
|
|
62
|
+
# and line-breaking with real font metrics. Lines that fall
|
|
63
|
+
# below the frame's bottom edge are clipped.
|
|
81
64
|
def self.render_run_lines(canvas, run, context, box, font, baseline_y)
|
|
82
65
|
size = run.point_size
|
|
83
66
|
glyphs = TextEngine::Shaper.shape(
|
|
@@ -105,6 +88,32 @@ module Idml
|
|
|
105
88
|
baseline_y
|
|
106
89
|
end
|
|
107
90
|
private_class_method :render_run_lines
|
|
91
|
+
|
|
92
|
+
# Fallback when no metrics are available: emit all runs as
|
|
93
|
+
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
94
|
+
# Uses pdfrb's measurement API directly (no Fontisan).
|
|
95
|
+
def self.simple_render(canvas, runs, context, box)
|
|
96
|
+
runs_for = build_rich_runs(runs, context)
|
|
97
|
+
return if runs_for.empty?
|
|
98
|
+
|
|
99
|
+
first_size = runs.first.point_size
|
|
100
|
+
canvas.text_rich(
|
|
101
|
+
runs_for,
|
|
102
|
+
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :simple_render
|
|
106
|
+
|
|
107
|
+
def self.build_rich_runs(runs, context)
|
|
108
|
+
runs.map do |run|
|
|
109
|
+
{
|
|
110
|
+
text: run.text,
|
|
111
|
+
font: context.font_ps_name,
|
|
112
|
+
size: run.point_size,
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :build_rich_runs
|
|
108
117
|
end
|
|
109
118
|
end
|
|
110
119
|
end
|
|
@@ -4,10 +4,10 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
# Renders a typed `Parts::Spread` onto a Pdfrb::Content::Canvas.
|
|
6
6
|
class SpreadRenderer
|
|
7
|
-
def initialize(
|
|
7
|
+
def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
|
|
8
8
|
package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
|
|
9
9
|
font_ref_resolver: nil, structure: nil)
|
|
10
|
-
@
|
|
10
|
+
@font_metrics = font_metrics
|
|
11
11
|
@font_ps_name = font_ps_name
|
|
12
12
|
@package = package
|
|
13
13
|
@layer_filter = layer_filter
|
|
@@ -19,7 +19,7 @@ module Idml
|
|
|
19
19
|
page_index: 0)
|
|
20
20
|
context_base = {
|
|
21
21
|
package: @package,
|
|
22
|
-
|
|
22
|
+
font_metrics: @font_metrics,
|
|
23
23
|
font_ref_resolver: @font_ref_resolver,
|
|
24
24
|
color_resolver: build_color_resolver,
|
|
25
25
|
font_ps_name: @font_ps_name,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module TextEngine
|
|
5
|
+
# Adapter that exposes the same measurement surface as
|
|
6
|
+
# `FontMetrics` (the Fontisan-based parser) but delegates to
|
|
7
|
+
# pdfrb's `Fonts` collection + a registered font resource Symbol.
|
|
8
|
+
#
|
|
9
|
+
# Once a font is registered with the pdfrb document via
|
|
10
|
+
# `document.fonts.add(path)`, pdfrb parses the TTF tables (cmap,
|
|
11
|
+
# hmtx, head, hhea) and exposes real per-glyph advance widths
|
|
12
|
+
# through `Fonts#glyph_width(resource, codepoint)` and
|
|
13
|
+
# `Fonts#metrics_for(resource)`. This adapter wraps that pair so
|
|
14
|
+
# the text engine (Shaper, LineBreaker) can use real metrics
|
|
15
|
+
# without any Fontisan dependency.
|
|
16
|
+
class PdfrbFontMetrics
|
|
17
|
+
attr_reader :resource, :fonts
|
|
18
|
+
|
|
19
|
+
def initialize(fonts_collection, resource)
|
|
20
|
+
@fonts = fonts_collection
|
|
21
|
+
@resource = resource
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def units_per_em
|
|
25
|
+
metrics_data[:units_per_em] || 1000
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ascent
|
|
29
|
+
metrics_data[:ascent] || 800
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def descent
|
|
33
|
+
metrics_data[:descent] || -200
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def line_gap
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns raw advance width in font units (not scaled by size).
|
|
41
|
+
def glyph_width(codepoint)
|
|
42
|
+
cp = codepoint.is_a?(String) ? codepoint.each_codepoint.first : codepoint
|
|
43
|
+
@fonts.glyph_width(@resource, cp).to_i
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def measure_text(text, size:)
|
|
47
|
+
return 0.0 if text.nil? || text.empty?
|
|
48
|
+
|
|
49
|
+
text.each_codepoint.sum do |cp|
|
|
50
|
+
glyph_width(cp) * size.to_f / units_per_em
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def kerning_pair(_left_cp, _right_cp)
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def postscript_name
|
|
59
|
+
@resource.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def family_name
|
|
63
|
+
postscript_name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def style_name
|
|
67
|
+
"Regular"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def path
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def metrics_data
|
|
77
|
+
@fonts.metrics_for(@resource) || {}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -5,8 +5,7 @@ module Idml
|
|
|
5
5
|
# geometry and produces positioned glyphs using font metrics from
|
|
6
6
|
# .ttf/.otf files. IDML-agnostic — works on any styled text.
|
|
7
7
|
module TextEngine
|
|
8
|
-
autoload :
|
|
9
|
-
autoload :FontResolver, "#{__dir__}/text_engine/font_resolver"
|
|
8
|
+
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
10
9
|
autoload :Shaper, "#{__dir__}/text_engine/shaper"
|
|
11
10
|
autoload :ShapedGlyph, "#{__dir__}/text_engine/shaper"
|
|
12
11
|
autoload :LineBreaker, "#{__dir__}/text_engine/line_breaker"
|
data/lib/idml/version.rb
CHANGED
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.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -23,20 +23,6 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: fontisan
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - ">="
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - ">="
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0'
|
|
40
26
|
- !ruby/object:Gem::Dependency
|
|
41
27
|
name: lutaml-model
|
|
42
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -414,10 +400,9 @@ files:
|
|
|
414
400
|
- lib/idml/render/style_resolver.rb
|
|
415
401
|
- lib/idml/text_engine.rb
|
|
416
402
|
- lib/idml/text_engine/cjk_layout.rb
|
|
417
|
-
- lib/idml/text_engine/font_metrics.rb
|
|
418
|
-
- lib/idml/text_engine/font_resolver.rb
|
|
419
403
|
- lib/idml/text_engine/justifier.rb
|
|
420
404
|
- lib/idml/text_engine/line_breaker.rb
|
|
405
|
+
- lib/idml/text_engine/pdfrb_font_metrics.rb
|
|
421
406
|
- lib/idml/text_engine/shaper.rb
|
|
422
407
|
- lib/idml/text_engine/vertical_layout.rb
|
|
423
408
|
- lib/idml/validation.rb
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fontisan"
|
|
4
|
-
|
|
5
|
-
module Idml
|
|
6
|
-
module TextEngine
|
|
7
|
-
# Reads OpenType/TrueType font metrics via Fontisan. Uses Fontisan's
|
|
8
|
-
# FontLoader to open the font file and locate table offsets, then
|
|
9
|
-
# parses the head, hhea, hmtx, and cmap tables directly from the
|
|
10
|
-
# binary. No ttfunk.
|
|
11
|
-
class FontMetrics
|
|
12
|
-
@cache = {}
|
|
13
|
-
|
|
14
|
-
def self.open(path)
|
|
15
|
-
@cache[path] ||= new(path)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.clear_cache
|
|
19
|
-
@cache.clear
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def initialize(path)
|
|
23
|
-
@path = path
|
|
24
|
-
@font = Fontisan::FontLoader.load(path)
|
|
25
|
-
@io = File.open(path, "rb")
|
|
26
|
-
parse_head
|
|
27
|
-
parse_hhea
|
|
28
|
-
parse_hmtx
|
|
29
|
-
parse_cmap
|
|
30
|
-
@width_cache = {}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
attr_reader :units_per_em, :ascent, :descent, :line_gap, :path
|
|
34
|
-
|
|
35
|
-
def glyph_width(codepoint)
|
|
36
|
-
@width_cache[codepoint] ||=
|
|
37
|
-
begin
|
|
38
|
-
glyph_id = @code_map[codepoint] || 0
|
|
39
|
-
metric = @advance_widths[glyph_id]
|
|
40
|
-
metric || @advance_widths.last || 0
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def kerning_pair(_left_cp, _right_cp)
|
|
45
|
-
0
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def measure_text(text, size:)
|
|
49
|
-
total = 0
|
|
50
|
-
text.each_codepoint { |cp| total += glyph_width(cp) }
|
|
51
|
-
total.to_f / units_per_em * size
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def postscript_name
|
|
55
|
-
@postscript_name ||= parse_name_entry(6) || "Unknown"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def family_name
|
|
59
|
-
@family_name ||= parse_name_entry(1) || "Unknown"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def style_name
|
|
63
|
-
@style_name ||= parse_name_entry(2) || "Regular"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def find_table(tag)
|
|
69
|
-
@font.tables.find { |entry| entry.tag == tag }
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def read_table_bytes(tag)
|
|
73
|
-
entry = find_table(tag)
|
|
74
|
-
return nil unless entry
|
|
75
|
-
|
|
76
|
-
@io.seek(entry.offset)
|
|
77
|
-
@io.read(entry.table_length)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def parse_head
|
|
81
|
-
data = read_table_bytes("head")
|
|
82
|
-
return unless data
|
|
83
|
-
|
|
84
|
-
@units_per_em = data[18, 2].unpack1("n")
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def parse_hhea
|
|
88
|
-
data = read_table_bytes("hhea")
|
|
89
|
-
return unless data
|
|
90
|
-
|
|
91
|
-
@ascent = sign_extend(data[4, 2].unpack1("n"))
|
|
92
|
-
@descent = sign_extend(data[6, 2].unpack1("n"))
|
|
93
|
-
@line_gap = sign_extend(data[8, 2].unpack1("n"))
|
|
94
|
-
@num_hmetrics = data[34, 2].unpack1("n")
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def parse_hmtx
|
|
98
|
-
data = read_table_bytes("hmtx")
|
|
99
|
-
return unless data
|
|
100
|
-
|
|
101
|
-
@advance_widths = []
|
|
102
|
-
@num_hmetrics.times do |i|
|
|
103
|
-
offset = i * 4
|
|
104
|
-
@advance_widths[i] = data[offset, 2].unpack1("n")
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def parse_cmap
|
|
109
|
-
data = read_table_bytes("cmap")
|
|
110
|
-
return unless data
|
|
111
|
-
|
|
112
|
-
_, num_subtables = data[0, 4].unpack("nn")
|
|
113
|
-
@code_map = {}
|
|
114
|
-
|
|
115
|
-
best_offset = nil
|
|
116
|
-
num_subtables.times do |i|
|
|
117
|
-
rec_off = 4 + (i * 4)
|
|
118
|
-
platform_id, encoding_id, offset = data[rec_off, 8].unpack("nnN")
|
|
119
|
-
if platform_id == 3 && encoding_id == 1
|
|
120
|
-
best_offset = offset
|
|
121
|
-
break
|
|
122
|
-
end
|
|
123
|
-
best_offset ||= offset if platform_id.zero?
|
|
124
|
-
end
|
|
125
|
-
return unless best_offset
|
|
126
|
-
|
|
127
|
-
parse_cmap_subtable(data, best_offset)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def parse_cmap_subtable(data, offset)
|
|
131
|
-
format = data[offset, 2].unpack1("n")
|
|
132
|
-
parse_cmap_format4(data, offset) if format == 4
|
|
133
|
-
parse_cmap_format0(data, offset) if format.zero?
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def sign_extend(uint16)
|
|
137
|
-
uint16 >= 32768 ? uint16 - 65536 : uint16
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def parse_cmap_format4(data, offset)
|
|
141
|
-
seg_count_x2 = data[offset + 6, 2].unpack1("n")
|
|
142
|
-
seg_count = seg_count_x2 / 2
|
|
143
|
-
|
|
144
|
-
end_start = offset + 14
|
|
145
|
-
start_start = end_start + seg_count_x2 + 2
|
|
146
|
-
delta_start = start_start + seg_count_x2
|
|
147
|
-
range_start = delta_start + seg_count_x2
|
|
148
|
-
|
|
149
|
-
seg_count.times do |i|
|
|
150
|
-
end_code = data[end_start + (i * 2), 2].unpack1("n")
|
|
151
|
-
start_code = data[start_start + (i * 2), 2].unpack1("n")
|
|
152
|
-
delta = sign_extend(data[delta_start + (i * 2), 2].unpack1("n"))
|
|
153
|
-
range_off = data[range_start + (i * 2), 2].unpack1("n")
|
|
154
|
-
|
|
155
|
-
(start_code..end_code).each do |cp|
|
|
156
|
-
if range_off.zero?
|
|
157
|
-
glyph_id = (cp + delta) & 0xFFFF
|
|
158
|
-
else
|
|
159
|
-
idx = range_start + (i * 2) + range_off + ((cp - start_code) * 2)
|
|
160
|
-
glyph_id = data[idx, 2].unpack1("n")
|
|
161
|
-
glyph_id = (glyph_id + delta) & 0xFFFF if glyph_id != 0
|
|
162
|
-
end
|
|
163
|
-
@code_map[cp] = glyph_id if glyph_id != 0
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def parse_cmap_format0(data, offset)
|
|
169
|
-
256.times do |cp|
|
|
170
|
-
glyph_id = data[offset + 6 + cp, 1].unpack1("C")
|
|
171
|
-
@code_map[cp] = glyph_id if glyph_id != 0
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def parse_name_entry(name_id)
|
|
176
|
-
data = read_table_bytes("name")
|
|
177
|
-
return nil unless data
|
|
178
|
-
|
|
179
|
-
_format, count, string_offset = data[0, 6].unpack("nnn")
|
|
180
|
-
best = nil
|
|
181
|
-
best_priority = 99
|
|
182
|
-
count.times do |i|
|
|
183
|
-
rec = 6 + (i * 12)
|
|
184
|
-
platform_id, encoding_id, _lang_id, id, length, str_off =
|
|
185
|
-
data[rec, 12].unpack("nnnnnn")
|
|
186
|
-
next unless id == name_id
|
|
187
|
-
|
|
188
|
-
priority = name_platform_priority(platform_id, encoding_id)
|
|
189
|
-
next if priority >= best_priority
|
|
190
|
-
|
|
191
|
-
raw = data[string_offset + str_off, length]
|
|
192
|
-
decoded = decode_name_string(raw, platform_id)
|
|
193
|
-
next unless decoded && !decoded.empty?
|
|
194
|
-
|
|
195
|
-
best = decoded
|
|
196
|
-
best_priority = priority
|
|
197
|
-
end
|
|
198
|
-
best
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Prefer Windows (platform 3, encoding 1) name records, then
|
|
202
|
-
# any Windows record, then Macintosh, then Unicode.
|
|
203
|
-
def name_platform_priority(platform_id, encoding_id)
|
|
204
|
-
case platform_id
|
|
205
|
-
when 3 then encoding_id == 1 ? 0 : 1
|
|
206
|
-
when 0 then 3
|
|
207
|
-
when 1 then 2
|
|
208
|
-
else 4
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def decode_name_string(raw, platform_id)
|
|
213
|
-
result =
|
|
214
|
-
case platform_id
|
|
215
|
-
when 3, 0
|
|
216
|
-
raw.encode("UTF-8", "UTF-16BE", invalid: :replace, undef: :replace)
|
|
217
|
-
when 1
|
|
218
|
-
raw.encode("UTF-8", "MacRoman", invalid: :replace, undef: :replace)
|
|
219
|
-
else
|
|
220
|
-
raw.dup.force_encoding("UTF-8").scrub("")
|
|
221
|
-
end
|
|
222
|
-
result.strip
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Idml
|
|
4
|
-
module TextEngine
|
|
5
|
-
# Maps IDML font references (family + style name) to .ttf/.otf
|
|
6
|
-
# file paths on disk via Fontisan. Searches system font dirs and
|
|
7
|
-
# any user-configured search paths.
|
|
8
|
-
class FontResolver
|
|
9
|
-
DEFAULT_SEARCH_PATHS = [
|
|
10
|
-
"/System/Library/Fonts",
|
|
11
|
-
"/Library/Fonts",
|
|
12
|
-
File.expand_path("~/Library/Fonts"),
|
|
13
|
-
"/usr/share/fonts",
|
|
14
|
-
File.expand_path("~/.local/share/fonts"),
|
|
15
|
-
].freeze
|
|
16
|
-
|
|
17
|
-
STYLE_ALIASES = {
|
|
18
|
-
"Regular" => %w[Regular Normal Book Roman Medium],
|
|
19
|
-
"Bold" => %w[Bold Semibold Heavy Black],
|
|
20
|
-
"Italic" => %w[Italic Oblique Slanted],
|
|
21
|
-
"Bold Italic" => %w[Bold\ Italic BoldOblique SemiboldItalic],
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
|
-
def initialize(search_paths: DEFAULT_SEARCH_PATHS)
|
|
25
|
-
@search_paths = search_paths
|
|
26
|
-
@cache = {}
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def resolve(family_name:, style_name: "Regular")
|
|
30
|
-
key = [family_name, style_name]
|
|
31
|
-
@cache[key] ||= find_font(family_name, style_name)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Search by PostScriptName (e.g., "MinionPro-Regular").
|
|
35
|
-
# More precise than family+style — maps directly to the font's
|
|
36
|
-
# internal PostScript name. Used when the IDML document references
|
|
37
|
-
# a specific PostScriptName from Fonts.xml.
|
|
38
|
-
def resolve_by_ps_name(ps_name)
|
|
39
|
-
return nil unless ps_name
|
|
40
|
-
|
|
41
|
-
@ps_cache ||= {}
|
|
42
|
-
return @ps_cache[ps_name] if @ps_cache.key?(ps_name)
|
|
43
|
-
|
|
44
|
-
@ps_cache[ps_name] = find_by_ps_name(ps_name)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def find_by_ps_name(ps_name)
|
|
50
|
-
@search_paths.each do |dir|
|
|
51
|
-
next unless Dir.exist?(dir)
|
|
52
|
-
|
|
53
|
-
Dir.glob(File.join(dir, "**", "*.ttf")).each do |path|
|
|
54
|
-
font = safe_load(path)
|
|
55
|
-
next unless font
|
|
56
|
-
|
|
57
|
-
return FontMetrics.open(path) if font.postscript_name == ps_name
|
|
58
|
-
end
|
|
59
|
-
Dir.glob(File.join(dir, "**", "*.otf")).each do |path|
|
|
60
|
-
font = safe_load(path)
|
|
61
|
-
next unless font
|
|
62
|
-
|
|
63
|
-
return FontMetrics.open(path) if font.postscript_name == ps_name
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def find_font(family, style)
|
|
70
|
-
candidates = STYLE_ALIASES[style] || [style]
|
|
71
|
-
@search_paths.each do |dir|
|
|
72
|
-
next unless Dir.exist?(dir)
|
|
73
|
-
|
|
74
|
-
Dir.glob(File.join(dir, "**", "*.ttf")).each do |path|
|
|
75
|
-
font = safe_load(path)
|
|
76
|
-
next unless font
|
|
77
|
-
|
|
78
|
-
return FontMetrics.open(path) if matches?(font, family, candidates)
|
|
79
|
-
end
|
|
80
|
-
Dir.glob(File.join(dir, "**", "*.otf")).each do |path|
|
|
81
|
-
font = safe_load(path)
|
|
82
|
-
next unless font
|
|
83
|
-
|
|
84
|
-
return FontMetrics.open(path) if matches?(font, family, candidates)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
nil
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def matches?(font, family, style_candidates)
|
|
91
|
-
return false unless font.family_name.match?(/#{Regexp.escape(family)}/i)
|
|
92
|
-
|
|
93
|
-
style_candidates.any? do |s|
|
|
94
|
-
font.style_name.match?(/#{Regexp.escape(s)}/i)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def safe_load(path)
|
|
99
|
-
FontMetrics.open(path)
|
|
100
|
-
rescue StandardError
|
|
101
|
-
nil
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|