rupdf 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {rupdf-0.2.0 → rupdf-0.2.2}/.gitignore +5 -1
- {rupdf-0.2.0 → rupdf-0.2.2}/CHANGELOG.md +34 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/Cargo.lock +1 -1
- {rupdf-0.2.0 → rupdf-0.2.2}/Cargo.toml +1 -1
- {rupdf-0.2.0 → rupdf-0.2.2}/PKG-INFO +8 -2
- {rupdf-0.2.0 → rupdf-0.2.2}/README.md +7 -1
- {rupdf-0.2.0 → rupdf-0.2.2}/pyproject.toml +1 -1
- rupdf-0.2.2/python/rupdf/__init__.py +13 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/_rupdf.pyi +3 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_pdf_structure.py +133 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/scripts/generate_test_pdf.py +22 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/writer.rs +28 -3
- {rupdf-0.2.0 → rupdf-0.2.2}/src/resources.rs +61 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/types.rs +2 -0
- rupdf-0.2.0/.specs/2026-05-12-missing-glyph-hard-error.md +0 -237
- rupdf-0.2.0/python/rupdf/__init__.py +0 -6
- {rupdf-0.2.0 → rupdf-0.2.2}/.claude/settings.local.json +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/.envrc +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/CLAUDE.md +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/LICENSE +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexMono-Bold.otf +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexMono-Regular.otf +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexSans-Bold.otf +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexSans-Regular.otf +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/test-png.png +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/assets/test-svg.svg +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/benches/render.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/benchmarks/README.md +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/benchmarks/run_benchmark.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/README.md +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/all_elements.json +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/multi_page.json +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/simple_text.json +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/stress_test.json +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/__init__.pyi +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/py.typed +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/__init__.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/conftest.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_font_fallback.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_interface.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/scripts/demo_pdf.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/scripts/position_test.py +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/elements/mod.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/elements/svg.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/error.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/lib.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/fonts.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/mod.rs +0 -0
- {rupdf-0.2.0 → rupdf-0.2.2}/src/runs.rs +0 -0
|
@@ -41,12 +41,16 @@ benchmarks/*.pdf
|
|
|
41
41
|
benchmarks/*.json
|
|
42
42
|
|
|
43
43
|
# Test assets directory (not versioned - provide your own images via CLI args)
|
|
44
|
-
tests/
|
|
44
|
+
# Root-anchored so python/tests/ stays tracked
|
|
45
|
+
/tests/
|
|
45
46
|
|
|
46
47
|
# Benchmark outputs
|
|
47
48
|
benchmarks/results/
|
|
48
49
|
*.bench.json
|
|
49
50
|
|
|
51
|
+
# Specs (session planning docs — see stringking specs convention)
|
|
52
|
+
.specs/
|
|
53
|
+
|
|
50
54
|
# Secrets
|
|
51
55
|
.env
|
|
52
56
|
.env.*
|
|
@@ -4,6 +4,40 @@ All notable changes to this project are documented here. The format is based
|
|
|
4
4
|
on [Keep a Changelog](https://keepachangelog.com/), and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.2.2] - 2026-07-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`strikethrough` option on text elements.** When `"strikethrough": true`,
|
|
12
|
+
a horizontal line is drawn through the text at the font's OS/2 strikeout
|
|
13
|
+
position, spanning the exact rendered run width, in the text color. Line
|
|
14
|
+
thickness also comes from the font's metrics; fonts lacking the OS/2
|
|
15
|
+
strikeout table fall back to mid cap-height position and a 1/18-em rule.
|
|
16
|
+
Because rupdf places the line using the same measured run width and
|
|
17
|
+
aligned left edge it uses to draw the glyphs, the strike tracks left-,
|
|
18
|
+
center-, and right-aligned text exactly. Defaults to `false`; applies to
|
|
19
|
+
single-line `text` elements (not `textbox`).
|
|
20
|
+
|
|
21
|
+
## [0.2.1] - 2026-07-19
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Textbox clipping no longer shears ascenders and descenders** in the
|
|
26
|
+
`capline` and `baseline` vertical alignment modes. `capline` aligns the
|
|
27
|
+
first line's cap top with the box top, placing ascenders (`b d f h k l`,
|
|
28
|
+
i/j dots, diacritics) above the box; `baseline` aligns the last line's
|
|
29
|
+
baseline with the box bottom, placing descenders (`g j p q y`) below it.
|
|
30
|
+
The clip rect previously matched the exact box, cutting that ink off
|
|
31
|
+
flat. The clip now extends by the overhang each mode implies
|
|
32
|
+
(`ascender − cap_height` above for `capline`, the descender depth below
|
|
33
|
+
for `baseline`), so mixed-case text renders whole. Line-overflow
|
|
34
|
+
clipping and the other alignment modes are unchanged.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- `__version__` is read from package metadata instead of being hardcoded,
|
|
39
|
+
so it can no longer drift from the built wheel's version.
|
|
40
|
+
|
|
7
41
|
## [0.2.0] - 2026-05-13
|
|
8
42
|
|
|
9
43
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rupdf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -125,7 +125,8 @@ Common page sizes:
|
|
|
125
125
|
"size": 12, # Font size in points
|
|
126
126
|
"color": (0, 0, 0, 255), # RGBA (optional, default black)
|
|
127
127
|
"align": "left", # "left", "center", or "right" (optional)
|
|
128
|
-
"vertical_anchor": "baseline"
|
|
128
|
+
"vertical_anchor": "baseline",# "baseline", "capline", or "center" (optional)
|
|
129
|
+
"strikethrough": False # Draw a line through the text (optional)
|
|
129
130
|
}
|
|
130
131
|
```
|
|
131
132
|
|
|
@@ -140,6 +141,11 @@ Common page sizes:
|
|
|
140
141
|
- `"capline"`: y is the top of capital letters
|
|
141
142
|
- `"center"`: y is the vertical center of capital letters
|
|
142
143
|
|
|
144
|
+
**Strikethrough:** when `"strikethrough": True`, a horizontal line is drawn
|
|
145
|
+
through the text at the font's strikeout position, spanning the exact rendered
|
|
146
|
+
run width, in the text color. Position and thickness come from the font's
|
|
147
|
+
metrics (with sensible fallbacks for fonts that lack them).
|
|
148
|
+
|
|
143
149
|
### TextBox
|
|
144
150
|
|
|
145
151
|
Multi-line text with word wrapping, like Illustrator's "area type".
|
|
@@ -102,7 +102,8 @@ Common page sizes:
|
|
|
102
102
|
"size": 12, # Font size in points
|
|
103
103
|
"color": (0, 0, 0, 255), # RGBA (optional, default black)
|
|
104
104
|
"align": "left", # "left", "center", or "right" (optional)
|
|
105
|
-
"vertical_anchor": "baseline"
|
|
105
|
+
"vertical_anchor": "baseline",# "baseline", "capline", or "center" (optional)
|
|
106
|
+
"strikethrough": False # Draw a line through the text (optional)
|
|
106
107
|
}
|
|
107
108
|
```
|
|
108
109
|
|
|
@@ -117,6 +118,11 @@ Common page sizes:
|
|
|
117
118
|
- `"capline"`: y is the top of capital letters
|
|
118
119
|
- `"center"`: y is the vertical center of capital letters
|
|
119
120
|
|
|
121
|
+
**Strikethrough:** when `"strikethrough": True`, a horizontal line is drawn
|
|
122
|
+
through the text at the font's strikeout position, spanning the exact rendered
|
|
123
|
+
run width, in the text color. Position and thickness come from the font's
|
|
124
|
+
metrics (with sensible fallbacks for fonts that lack them).
|
|
125
|
+
|
|
120
126
|
### TextBox
|
|
121
127
|
|
|
122
128
|
Multi-line text with word wrapping, like Illustrator's "area type".
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""rupdf - A fast, minimal PDF page renderer."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
4
|
+
|
|
5
|
+
from rupdf._rupdf import render_pdf, RupdfError
|
|
6
|
+
|
|
7
|
+
__all__ = ["render_pdf", "RupdfError"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = _pkg_version("rupdf")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
# Editable or source install with no installed metadata; not worth failing.
|
|
13
|
+
__version__ = "unknown"
|
|
@@ -32,6 +32,9 @@ class TextElement(TypedDict, total=False):
|
|
|
32
32
|
color: Color
|
|
33
33
|
align: HAlign
|
|
34
34
|
vertical_anchor: VerticalAnchor
|
|
35
|
+
# Draw a horizontal line through the text at the font's strikeout
|
|
36
|
+
# position, spanning the rendered run width, in the text color.
|
|
37
|
+
strikethrough: bool
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class TextBoxElement(TypedDict, total=False):
|
|
@@ -4,6 +4,8 @@ Tests that generated PDFs have correct structure without
|
|
|
4
4
|
comparing exact byte sequences.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import re
|
|
8
|
+
|
|
7
9
|
import pytest
|
|
8
10
|
import rupdf
|
|
9
11
|
|
|
@@ -389,6 +391,137 @@ class TestBarcodes:
|
|
|
389
391
|
assert pdf.startswith(b"%PDF-")
|
|
390
392
|
|
|
391
393
|
|
|
394
|
+
class TestTextBoxClipping:
|
|
395
|
+
"""The textbox clip must not shear ink that alignment modes place
|
|
396
|
+
outside the box (capline: ascenders above the top; baseline:
|
|
397
|
+
descenders below the bottom)."""
|
|
398
|
+
|
|
399
|
+
# Box geometry in user coords: page 288x144, box at (18, 36), 252x72.
|
|
400
|
+
# In PDF coords (bottom-left origin): bottom = 144 - 36 - 72 = 36, top = 108.
|
|
401
|
+
BOX_BOTTOM = 36.0
|
|
402
|
+
BOX_TOP = 108.0
|
|
403
|
+
|
|
404
|
+
def _clip_rect(self, font_path, text_align_y):
|
|
405
|
+
doc = {
|
|
406
|
+
"pages": [{
|
|
407
|
+
"size": (288.0, 144.0),
|
|
408
|
+
"elements": [{
|
|
409
|
+
"type": "textbox",
|
|
410
|
+
"x": 18.0, "y": 36.0, "w": 252.0, "h": 72.0,
|
|
411
|
+
"text": "Midfield",
|
|
412
|
+
"font": "f", "size": 16.0,
|
|
413
|
+
"text_align_y": text_align_y,
|
|
414
|
+
}],
|
|
415
|
+
}],
|
|
416
|
+
"resources": {"fonts": {"f": {"path": font_path}}},
|
|
417
|
+
}
|
|
418
|
+
pdf = rupdf.render_pdf(doc, compress=False)
|
|
419
|
+
m = re.search(rb"([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) re\nW\nn", pdf)
|
|
420
|
+
assert m is not None, "No clip rect found in content stream"
|
|
421
|
+
x, bottom, w, h = (float(v) for v in m.groups())
|
|
422
|
+
return x, bottom, w, h
|
|
423
|
+
|
|
424
|
+
def test_capline_clip_extends_above_box(self, font_path):
|
|
425
|
+
"""Capline puts the cap top at the box top, so ascenders and i-dots
|
|
426
|
+
rise above it; the clip's top edge must extend up past the box."""
|
|
427
|
+
x, bottom, w, h = self._clip_rect(font_path, "capline")
|
|
428
|
+
assert bottom == pytest.approx(self.BOX_BOTTOM)
|
|
429
|
+
assert bottom + h > self.BOX_TOP
|
|
430
|
+
assert x == pytest.approx(18.0)
|
|
431
|
+
assert w == pytest.approx(252.0)
|
|
432
|
+
|
|
433
|
+
def test_baseline_clip_extends_below_box(self, font_path):
|
|
434
|
+
"""Baseline puts the last baseline at the box bottom, so descenders
|
|
435
|
+
hang below it; the clip's bottom edge must extend down past the box."""
|
|
436
|
+
x, bottom, w, h = self._clip_rect(font_path, "baseline")
|
|
437
|
+
assert bottom < self.BOX_BOTTOM
|
|
438
|
+
assert bottom + h == pytest.approx(self.BOX_TOP)
|
|
439
|
+
|
|
440
|
+
@pytest.mark.parametrize("align", ["top", "center", "bottom"])
|
|
441
|
+
def test_other_modes_clip_exactly_to_box(self, font_path, align):
|
|
442
|
+
"""Modes with no intentional overhang keep the exact box clip."""
|
|
443
|
+
x, bottom, w, h = self._clip_rect(font_path, align)
|
|
444
|
+
assert bottom == pytest.approx(self.BOX_BOTTOM)
|
|
445
|
+
assert h == pytest.approx(72.0)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TestStrikethrough:
|
|
449
|
+
"""Struck text draws a horizontal line through the glyph run, placed by
|
|
450
|
+
the font's strikeout metrics and spanning the measured run width."""
|
|
451
|
+
|
|
452
|
+
# Page 400x200; text baseline-anchored at y=72 → PDF baseline_y = 128.
|
|
453
|
+
PAGE = (400.0, 200.0)
|
|
454
|
+
BASELINE_Y = 128.0
|
|
455
|
+
SIZE = 24.0
|
|
456
|
+
ANCHOR_X = 150.0
|
|
457
|
+
|
|
458
|
+
STRIKE_RE = re.compile(rb"([-\d.]+) ([-\d.]+) m\n([-\d.]+) ([-\d.]+) l\nS")
|
|
459
|
+
|
|
460
|
+
def _render(self, font_path, strikethrough, align="left", **extra):
|
|
461
|
+
element = {
|
|
462
|
+
"type": "text",
|
|
463
|
+
"x": self.ANCHOR_X, "y": 72.0,
|
|
464
|
+
"text": "$10.00",
|
|
465
|
+
"font": "f", "size": self.SIZE,
|
|
466
|
+
"align": align,
|
|
467
|
+
"vertical_anchor": "baseline",
|
|
468
|
+
**extra,
|
|
469
|
+
}
|
|
470
|
+
if strikethrough is not None:
|
|
471
|
+
element["strikethrough"] = strikethrough
|
|
472
|
+
doc = {
|
|
473
|
+
"pages": [{"size": self.PAGE, "elements": [element]}],
|
|
474
|
+
"resources": {"fonts": {"f": {"path": font_path}}},
|
|
475
|
+
}
|
|
476
|
+
return rupdf.render_pdf(doc, compress=False)
|
|
477
|
+
|
|
478
|
+
def _strike_line(self, font_path, align="left"):
|
|
479
|
+
pdf = self._render(font_path, True, align)
|
|
480
|
+
m = self.STRIKE_RE.search(pdf)
|
|
481
|
+
assert m is not None, "No stroked line found in content stream"
|
|
482
|
+
x1, y1, x2, y2 = (float(v) for v in m.groups())
|
|
483
|
+
return x1, y1, x2, y2
|
|
484
|
+
|
|
485
|
+
@pytest.mark.parametrize("strikethrough", [None, False], ids=["absent", "false"])
|
|
486
|
+
def test_no_strike_by_default(self, font_path, strikethrough):
|
|
487
|
+
pdf = self._render(font_path, strikethrough)
|
|
488
|
+
assert self.STRIKE_RE.search(pdf) is None
|
|
489
|
+
|
|
490
|
+
def test_strike_is_horizontal_above_baseline(self, font_path):
|
|
491
|
+
x1, y1, x2, y2 = self._strike_line(font_path)
|
|
492
|
+
assert y1 == y2
|
|
493
|
+
# Between the baseline and the top of the em box — i.e. through the text.
|
|
494
|
+
assert self.BASELINE_Y < y1 < self.BASELINE_Y + self.SIZE
|
|
495
|
+
|
|
496
|
+
def test_left_align_starts_at_anchor(self, font_path):
|
|
497
|
+
x1, y1, x2, y2 = self._strike_line(font_path, "left")
|
|
498
|
+
assert x1 == pytest.approx(self.ANCHOR_X)
|
|
499
|
+
assert x2 > x1
|
|
500
|
+
|
|
501
|
+
def test_center_align_centers_on_anchor(self, font_path):
|
|
502
|
+
x1, y1, x2, y2 = self._strike_line(font_path, "center")
|
|
503
|
+
assert (x1 + x2) / 2 == pytest.approx(self.ANCHOR_X, abs=0.01)
|
|
504
|
+
|
|
505
|
+
def test_right_align_ends_at_anchor(self, font_path):
|
|
506
|
+
x1, y1, x2, y2 = self._strike_line(font_path, "right")
|
|
507
|
+
assert x2 == pytest.approx(self.ANCHOR_X)
|
|
508
|
+
assert x1 < x2
|
|
509
|
+
|
|
510
|
+
def test_width_matches_run_across_alignments(self, font_path):
|
|
511
|
+
widths = {
|
|
512
|
+
align: (lambda p: p[2] - p[0])(self._strike_line(font_path, align))
|
|
513
|
+
for align in ("left", "center", "right")
|
|
514
|
+
}
|
|
515
|
+
assert widths["left"] == pytest.approx(widths["center"], abs=0.01)
|
|
516
|
+
assert widths["left"] == pytest.approx(widths["right"], abs=0.01)
|
|
517
|
+
# Sanity: roughly the width of a 6-glyph run, not the whole page.
|
|
518
|
+
assert 0.5 * self.SIZE < widths["left"] < 6 * self.SIZE
|
|
519
|
+
|
|
520
|
+
def test_strike_uses_text_color(self, font_path):
|
|
521
|
+
pdf = self._render(font_path, True, color=(255, 0, 0, 255))
|
|
522
|
+
assert b"1 0 0 RG" in pdf
|
|
523
|
+
|
|
524
|
+
|
|
392
525
|
class TestPageDimensions:
|
|
393
526
|
"""Test page dimension handling."""
|
|
394
527
|
|
|
@@ -163,6 +163,28 @@ def build_document(fonts: dict, images: dict) -> dict:
|
|
|
163
163
|
"size": 12,
|
|
164
164
|
"color": (0, 0, 200, 255),
|
|
165
165
|
})
|
|
166
|
+
y += 18
|
|
167
|
+
|
|
168
|
+
# Strikethrough text (superseded-price pattern)
|
|
169
|
+
elements_page1.append({
|
|
170
|
+
"type": "text",
|
|
171
|
+
"x": MARGIN,
|
|
172
|
+
"y": y,
|
|
173
|
+
"text": "$10.00 struck through",
|
|
174
|
+
"font": default_font,
|
|
175
|
+
"size": 12,
|
|
176
|
+
"color": (0, 0, 0, 255),
|
|
177
|
+
"strikethrough": True,
|
|
178
|
+
})
|
|
179
|
+
elements_page1.append({
|
|
180
|
+
"type": "text",
|
|
181
|
+
"x": MARGIN + 170,
|
|
182
|
+
"y": y,
|
|
183
|
+
"text": "$8.50 not struck",
|
|
184
|
+
"font": default_font,
|
|
185
|
+
"size": 12,
|
|
186
|
+
"color": (0, 0, 0, 255),
|
|
187
|
+
})
|
|
166
188
|
y += 35
|
|
167
189
|
|
|
168
190
|
# --- RECTANGLE SECTION ---
|
|
@@ -452,6 +452,18 @@ impl<'a> PdfGenerator<'a> {
|
|
|
452
452
|
}
|
|
453
453
|
content.end_text();
|
|
454
454
|
|
|
455
|
+
// Paths can't go inside a BT/ET text object, so the strike is drawn
|
|
456
|
+
// after end_text but within the same save/restore so alpha still applies.
|
|
457
|
+
if text.strikethrough {
|
|
458
|
+
let strike_y = baseline_y + primary.strikeout_position_pts(text.size);
|
|
459
|
+
let thickness = primary.strikeout_thickness_pts(text.size);
|
|
460
|
+
content.set_stroke_rgb(r, g, b);
|
|
461
|
+
content.set_line_width(thickness);
|
|
462
|
+
content.move_to(x, strike_y);
|
|
463
|
+
content.line_to(x + total_width, strike_y);
|
|
464
|
+
content.stroke();
|
|
465
|
+
}
|
|
466
|
+
|
|
455
467
|
content.restore_state();
|
|
456
468
|
|
|
457
469
|
Ok(())
|
|
@@ -518,11 +530,24 @@ impl<'a> PdfGenerator<'a> {
|
|
|
518
530
|
TextAlignY::Bottom => box_top + textbox.h - last_baseline_offset - descender,
|
|
519
531
|
};
|
|
520
532
|
|
|
521
|
-
// Step 5: Set up clipping
|
|
533
|
+
// Step 5: Set up clipping. Capline/Baseline intentionally place ink
|
|
534
|
+
// outside the box (ascenders above the cap line, descenders below the
|
|
535
|
+
// baseline), so extend the clip by that overhang on the relevant edge;
|
|
536
|
+
// the clip only needs to cut off whole lines that don't fit.
|
|
522
537
|
content.save_state();
|
|
523
538
|
|
|
524
|
-
let
|
|
525
|
-
|
|
539
|
+
let (clip_top_extra, clip_bottom_extra) = match textbox.text_align_y {
|
|
540
|
+
TextAlignY::Capline => ((ascender - cap_height).max(0.0), 0.0),
|
|
541
|
+
TextAlignY::Baseline => (0.0, descender),
|
|
542
|
+
_ => (0.0, 0.0),
|
|
543
|
+
};
|
|
544
|
+
let pdf_box_bottom = page_height - box_top - textbox.h - clip_bottom_extra;
|
|
545
|
+
content.rect(
|
|
546
|
+
box_left,
|
|
547
|
+
pdf_box_bottom,
|
|
548
|
+
textbox.w,
|
|
549
|
+
textbox.h + clip_top_extra + clip_bottom_extra,
|
|
550
|
+
);
|
|
526
551
|
content.clip_nonzero();
|
|
527
552
|
content.end_path();
|
|
528
553
|
|
|
@@ -11,6 +11,8 @@ pub struct LoadedFont {
|
|
|
11
11
|
pub ascender: i16,
|
|
12
12
|
pub descender: i16,
|
|
13
13
|
pub cap_height: i16, // Height of capital letters
|
|
14
|
+
pub strikeout_position: i16, // Strikeout line position above baseline
|
|
15
|
+
pub strikeout_thickness: i16, // Strikeout line thickness
|
|
14
16
|
pub glyph_widths: HashMap<u16, u16>, // glyph_id -> advance width
|
|
15
17
|
pub cmap: HashMap<char, u16>, // char -> glyph_id
|
|
16
18
|
}
|
|
@@ -43,6 +45,16 @@ impl LoadedFont {
|
|
|
43
45
|
})
|
|
44
46
|
.unwrap_or_else(|| (ascender as f32 * 0.7) as i16);
|
|
45
47
|
|
|
48
|
+
// Strikeout metrics from OS/2 table. Fallbacks: mid cap-height centers
|
|
49
|
+
// the strike on lining figures; thickness ~1/18 em is a typical rule.
|
|
50
|
+
let strikeout = face.strikeout_metrics();
|
|
51
|
+
let strikeout_position = strikeout
|
|
52
|
+
.map(|m| m.position)
|
|
53
|
+
.unwrap_or_else(|| (cap_height as f32 * 0.5) as i16);
|
|
54
|
+
let strikeout_thickness = strikeout
|
|
55
|
+
.map(|m| m.thickness)
|
|
56
|
+
.unwrap_or_else(|| (units_per_em as f32 / 18.0) as i16);
|
|
57
|
+
|
|
46
58
|
// Extract PostScript name from name table (name_id 6)
|
|
47
59
|
let postscript_name = face
|
|
48
60
|
.names()
|
|
@@ -83,6 +95,8 @@ impl LoadedFont {
|
|
|
83
95
|
ascender,
|
|
84
96
|
descender,
|
|
85
97
|
cap_height,
|
|
98
|
+
strikeout_position,
|
|
99
|
+
strikeout_thickness,
|
|
86
100
|
glyph_widths,
|
|
87
101
|
cmap,
|
|
88
102
|
})
|
|
@@ -119,6 +133,16 @@ impl LoadedFont {
|
|
|
119
133
|
pub fn descender_pts(&self, size: f32) -> f32 {
|
|
120
134
|
self.descender as f32 * size / self.units_per_em as f32
|
|
121
135
|
}
|
|
136
|
+
|
|
137
|
+
/// Strikeout line position above baseline, in points, for the given size.
|
|
138
|
+
pub fn strikeout_position_pts(&self, size: f32) -> f32 {
|
|
139
|
+
self.strikeout_position as f32 * size / self.units_per_em as f32
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Strikeout line thickness, in points, for the given size.
|
|
143
|
+
pub fn strikeout_thickness_pts(&self, size: f32) -> f32 {
|
|
144
|
+
self.strikeout_thickness as f32 * size / self.units_per_em as f32
|
|
145
|
+
}
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
/// Loaded image data
|
|
@@ -221,3 +245,40 @@ impl LoadedResources {
|
|
|
221
245
|
.ok_or_else(|| RupdfError::MissingImage(name.to_string()))
|
|
222
246
|
}
|
|
223
247
|
}
|
|
248
|
+
|
|
249
|
+
#[cfg(test)]
|
|
250
|
+
mod tests {
|
|
251
|
+
use super::*;
|
|
252
|
+
use std::path::PathBuf;
|
|
253
|
+
|
|
254
|
+
fn load_sans() -> LoadedFont {
|
|
255
|
+
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
256
|
+
.join("assets")
|
|
257
|
+
.join("IBMPlexSans-Regular.otf");
|
|
258
|
+
LoadedFont::load("sans", &FontSource::Path(path.to_str().unwrap().to_string())).unwrap()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn strikeout_metrics_extracted_from_os2() {
|
|
263
|
+
let font = load_sans();
|
|
264
|
+
// IBM Plex Sans carries OS/2 strikeout metrics: the position sits
|
|
265
|
+
// above the baseline but below cap height, and the rule has weight.
|
|
266
|
+
assert!(font.strikeout_position > 0);
|
|
267
|
+
assert!(font.strikeout_position < font.cap_height);
|
|
268
|
+
assert!(font.strikeout_thickness > 0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
fn strikeout_accessors_scale_by_size() {
|
|
273
|
+
let font = load_sans();
|
|
274
|
+
// At size == units_per_em, points equal raw font units.
|
|
275
|
+
let em = font.units_per_em as f32;
|
|
276
|
+
assert_eq!(font.strikeout_position_pts(em), font.strikeout_position as f32);
|
|
277
|
+
assert_eq!(font.strikeout_thickness_pts(em), font.strikeout_thickness as f32);
|
|
278
|
+
// Linear in size.
|
|
279
|
+
assert!(
|
|
280
|
+
(font.strikeout_position_pts(24.0) - 2.0 * font.strikeout_position_pts(12.0)).abs()
|
|
281
|
+
< 1e-4
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -223,6 +223,7 @@ pub struct TextElement {
|
|
|
223
223
|
pub color: Color,
|
|
224
224
|
pub align: TextAlign,
|
|
225
225
|
pub vertical_anchor: VerticalAnchor,
|
|
226
|
+
pub strikethrough: bool,
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
/// Rectangle element
|
|
@@ -526,6 +527,7 @@ impl Element {
|
|
|
526
527
|
color: with_element_context(opt_or(dict, "color", Color::black()), index)?,
|
|
527
528
|
align: with_element_context(opt_default(dict, "align"), index)?,
|
|
528
529
|
vertical_anchor: with_element_context(opt_default(dict, "vertical_anchor"), index)?,
|
|
530
|
+
strikethrough: with_element_context(opt_or(dict, "strikethrough", false), index)?,
|
|
529
531
|
})),
|
|
530
532
|
|
|
531
533
|
"textbox" => {
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
# Font fallback chains for text + textbox
|
|
2
|
-
|
|
3
|
-
## Summary
|
|
4
|
-
|
|
5
|
-
`render_pdf` raises `RupdfError::MissingGlyph` the moment a text element
|
|
6
|
-
contains a character absent from the chosen font's cmap. This blocks a
|
|
7
|
-
P1 prod incident in StringKing (GarmentVin 239378 has `body_name = "Tony
|
|
8
|
-
❤ Dorn"`; the heart aborts the whole label) and the same class of bug
|
|
9
|
-
recurs for any non-Latin character — CJK, Arabic, Cyrillic, etc. — in
|
|
10
|
-
user-supplied free-text fields.
|
|
11
|
-
|
|
12
|
-
Fix: support per-element font fallback chains. The caller passes a
|
|
13
|
-
primary font plus an ordered list of fallbacks, rupdf walks each
|
|
14
|
-
character against the chain, builds contiguous same-font sub-runs, and
|
|
15
|
-
renders/wraps with the per-character font assignments. Chars covered
|
|
16
|
-
by no font in the chain are dropped by default (configurable to raise).
|
|
17
|
-
|
|
18
|
-
## API
|
|
19
|
-
|
|
20
|
-
### Text element
|
|
21
|
-
|
|
22
|
-
```python
|
|
23
|
-
{"type": "text",
|
|
24
|
-
"text": "Tony ❤ 山田",
|
|
25
|
-
"font": "body", # primary; drives metrics
|
|
26
|
-
"font_fallback": ["emoji", "body_jp"], # tried in order
|
|
27
|
-
"missing_glyph_policy": "drop", # "drop" (default) | "raise"
|
|
28
|
-
"x": ..., "y": ..., "size": 12, "color": (0,0,0,255), ...}
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
### TextBox element
|
|
32
|
-
|
|
33
|
-
Same `font` / `font_fallback` / `missing_glyph_policy` keys. Wrapping
|
|
34
|
-
honors per-character advances from the resolved fonts.
|
|
35
|
-
|
|
36
|
-
### Backwards compatibility
|
|
37
|
-
|
|
38
|
-
- Existing `{font: "body"}` with no `font_fallback` keeps working
|
|
39
|
-
unchanged.
|
|
40
|
-
- `font_fallback: []` is identical to omitting the key.
|
|
41
|
-
- All fallback names must exist in `resources.fonts`. Unknown name →
|
|
42
|
-
`MissingFont` error (current behavior).
|
|
43
|
-
|
|
44
|
-
## Semantics
|
|
45
|
-
|
|
46
|
-
**Primary font drives metrics.** Line height, ascender, descender, cap
|
|
47
|
-
height, and baseline computation all use the primary font's metrics
|
|
48
|
-
regardless of which fallback covers a given character. Fallback chars
|
|
49
|
-
render at the textbox's `size`, scaled to the same baseline as the
|
|
50
|
-
primary. Rationale: a fallback that has a wildly different ascender
|
|
51
|
-
(emoji fonts are notorious for this) must not shift the layout of the
|
|
52
|
-
line it appears in.
|
|
53
|
-
|
|
54
|
-
**Per-character resolution.** For each char in `text`, walk `[font,
|
|
55
|
-
*font_fallback]` and assign the first font whose cmap contains the
|
|
56
|
-
char. Control characters (`\n`, `\t`) are not looked up — they pass
|
|
57
|
-
through.
|
|
58
|
-
|
|
59
|
-
**No coverage.** When no font in the chain covers a char:
|
|
60
|
-
- `missing_glyph_policy="drop"` (default): silently strip the char.
|
|
61
|
-
- `missing_glyph_policy="raise"`: raise `MissingGlyph` (current
|
|
62
|
-
behavior).
|
|
63
|
-
|
|
64
|
-
`drop` is the default because the immediate use case is label
|
|
65
|
-
printing where tofu is unacceptable and any visible artifact looks
|
|
66
|
-
broken. `raise` is one kwarg away for callers that want strict
|
|
67
|
-
detection.
|
|
68
|
-
|
|
69
|
-
**Internal sub-runs.** Contiguous same-font characters become a
|
|
70
|
-
sub-run. `"Tony ❤ 山田"` against `["body", "emoji", "body_jp"]`
|
|
71
|
-
resolves to:
|
|
72
|
-
|
|
73
|
-
```
|
|
74
|
-
[Run("Tony ", body), Run("❤", emoji), Run(" ", body), Run("山田", body_jp)]
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
The space between `❤` and `山田` is in `body` (every Latin font covers
|
|
78
|
-
ASCII space) and stays in `body`.
|
|
79
|
-
|
|
80
|
-
**Wrapping with mixed runs.** Word boundaries are still split on
|
|
81
|
-
whitespace. A word's width is the sum of per-character advances from
|
|
82
|
-
its resolved font(s). A word may straddle sub-runs — e.g. `"café"`
|
|
83
|
-
where `é` falls back. Wrap decisions use the actual rendered width.
|
|
84
|
-
|
|
85
|
-
**Emission.** Within a line, the content stream switches fonts at
|
|
86
|
-
sub-run boundaries: emit a `Tf` operator with the new font alias, then
|
|
87
|
-
the `Tj` for that sub-run's glyphs, then `Tf` again at the next
|
|
88
|
-
boundary. Existing single-font fast path stays unchanged when no
|
|
89
|
-
fallback resolves.
|
|
90
|
-
|
|
91
|
-
## Implementation
|
|
92
|
-
|
|
93
|
-
### Module shape
|
|
94
|
-
|
|
95
|
-
Factor the resolver into a standalone reusable function so the future
|
|
96
|
-
styled-runs API (see below) can call it per user-supplied run:
|
|
97
|
-
|
|
98
|
-
```rust
|
|
99
|
-
// src/runs.rs (new)
|
|
100
|
-
|
|
101
|
-
pub struct ResolvedChar {
|
|
102
|
-
pub ch: char,
|
|
103
|
-
pub font_alias: String, // which font in the chain covered it
|
|
104
|
-
pub glyph_id: u16,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
pub struct ResolvedRun {
|
|
108
|
-
pub font_alias: String,
|
|
109
|
-
pub text: String, // for ToUnicode reconstruction
|
|
110
|
-
pub glyphs: Vec<u16>,
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
pub fn resolve(
|
|
114
|
-
text: &str,
|
|
115
|
-
chain: &[&LoadedFont],
|
|
116
|
-
chain_names: &[&str],
|
|
117
|
-
policy: MissingGlyphPolicy,
|
|
118
|
-
) -> Result<Vec<ResolvedRun>>;
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
This function is the only place that does cmap walking. `text_width`,
|
|
122
|
-
`wrap_text`, `FontEmbedder::use_text`, and the emit path all consume
|
|
123
|
-
the `Vec<ResolvedRun>` output.
|
|
124
|
-
|
|
125
|
-
### Files touched
|
|
126
|
-
|
|
127
|
-
| File | Change |
|
|
128
|
-
| --- | --- |
|
|
129
|
-
| `src/error.rs` | Keep `MissingGlyph`; add `MissingGlyphPolicy` enum |
|
|
130
|
-
| `src/types.rs` | Add `font_fallback: Vec<String>` and `missing_glyph_policy: MissingGlyphPolicy` to `TextElement` / `TextBoxElement` parsers |
|
|
131
|
-
| `src/resources.rs` | `LoadedFont::glyph_id_opt(ch) -> Option<u16>` primitive (no policy, no error) |
|
|
132
|
-
| `src/runs.rs` | New — the resolver |
|
|
133
|
-
| `src/pdf/fonts.rs` | `FontEmbedder::use_run(&ResolvedRun)` |
|
|
134
|
-
| `src/pdf/writer.rs` | First pass iterates resolved runs per text/textbox to register glyphs in the right embedder; emit path switches fonts via `Tf` at sub-run boundaries |
|
|
135
|
-
| `src/lib.rs` | No signature change to `render_pdf` (per-element control) |
|
|
136
|
-
| `python/rupdf/_rupdf.pyi` | Add `font_fallback` and `missing_glyph_policy` to TextElement/TextBoxElement TypedDicts |
|
|
137
|
-
| `python/tests/` | Coverage: emoji fallback, CJK fallback, drop vs raise, no-fallback unchanged |
|
|
138
|
-
|
|
139
|
-
### Measurement
|
|
140
|
-
|
|
141
|
-
`text_width(text, size, chain)` becomes:
|
|
142
|
-
|
|
143
|
-
```rust
|
|
144
|
-
let runs = runs::resolve(text, chain, ...)?;
|
|
145
|
-
let mut width = 0.0;
|
|
146
|
-
for run in &runs {
|
|
147
|
-
let scale = size / run.font.units_per_em as f32;
|
|
148
|
-
for &gid in &run.glyphs {
|
|
149
|
-
width += run.font.advance_width(gid) as f32 * scale;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
Ok(width)
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### Wrapping
|
|
156
|
-
|
|
157
|
-
`wrap_text` is rewritten to operate on a `Vec<ResolvedRun>` instead of
|
|
158
|
-
a `&str`. Word boundaries are detected by walking the original text
|
|
159
|
-
char-by-char; a word's width sums advances regardless of which sub-run
|
|
160
|
-
contributes each char. Line breaks split sub-runs as needed (a sub-run
|
|
161
|
-
straddling a line break becomes two sub-runs, one per line).
|
|
162
|
-
|
|
163
|
-
### Emission
|
|
164
|
-
|
|
165
|
-
`render_textbox` and `render_text` iterate the per-line resolved
|
|
166
|
-
sub-runs. For each sub-run on a line, emit:
|
|
167
|
-
|
|
168
|
-
```
|
|
169
|
-
/AliasN size Tf
|
|
170
|
-
<hex-encoded glyphs> Tj
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
`Tj` positioning uses `Td` offsets within the same `BT`/`ET` block;
|
|
174
|
-
font switches do not require a state reset.
|
|
175
|
-
|
|
176
|
-
## Forward compatibility with styled runs
|
|
177
|
-
|
|
178
|
-
The styled-runs API (deferred to a separate change) will look like:
|
|
179
|
-
|
|
180
|
-
```python
|
|
181
|
-
{"type": "textbox",
|
|
182
|
-
"runs": [
|
|
183
|
-
{"text": "Order ", "font": "body", "size": 12, "color": (0,0,0,255)},
|
|
184
|
-
{"text": "G239378", "font": "body_bold", "size": 12, "color": (0,0,0,255)},
|
|
185
|
-
{"text": " — rush", "font": "body", "size": 12, "color": (200,0,0,255)},
|
|
186
|
-
],
|
|
187
|
-
"font_fallback": ["emoji", "body_jp"], # applies to every run
|
|
188
|
-
...}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
Compatibility requirements honored by this spec:
|
|
192
|
-
|
|
193
|
-
- Textbox-level `font_fallback` resolves uniformly against each
|
|
194
|
-
user-run's `font` — same `runs::resolve` function, called per user-run.
|
|
195
|
-
- Line height with mixed sizes: deferred; current spec uses primary
|
|
196
|
-
font metrics, which works for the styled-runs case when the textbox
|
|
197
|
-
has a defaultable `font` field. Future change: max-ascender across
|
|
198
|
-
runs on each line.
|
|
199
|
-
- The internal `ResolvedRun` type already carries the font alias; the
|
|
200
|
-
styled-runs change adds `size`, `color`, etc. to it without breaking
|
|
201
|
-
existing consumers.
|
|
202
|
-
|
|
203
|
-
If we discover during implementation that a different internal
|
|
204
|
-
representation makes styled-runs cleaner, fine to revisit — the
|
|
205
|
-
external `font_fallback` API doesn't depend on the internal shape.
|
|
206
|
-
|
|
207
|
-
## Out of scope
|
|
208
|
-
|
|
209
|
-
- **Styled runs.** Designed for compatibility; implementation deferred
|
|
210
|
-
until a concrete consumer (sklib) needs inline styling. Tracked
|
|
211
|
-
separately when that need surfaces.
|
|
212
|
-
- **Per-run fallback overrides.** Textbox-level fallback applies to
|
|
213
|
-
everything. If a future use case needs per-run override, additive
|
|
214
|
-
feature.
|
|
215
|
-
- **Complex script shaping, ligatures, RTL.** Unrelated; rupdf doesn't
|
|
216
|
-
shape today and this change doesn't introduce it.
|
|
217
|
-
- **Warning side-channel** for stripped chars. Nice-to-have. Sklib can
|
|
218
|
-
pre-walk cmaps if it wants visibility; not blocking prod.
|
|
219
|
-
- **Bundled fonts.** rupdf continues to ship zero font assets. Callers
|
|
220
|
-
supply all fonts via `resources.fonts`.
|
|
221
|
-
|
|
222
|
-
## Priority
|
|
223
|
-
|
|
224
|
-
P1 — blocking sewing-ticket / package-label / garment-tag rendering in
|
|
225
|
-
p1 prod for any order with a non-cmap-covered char in user-supplied
|
|
226
|
-
text. At least one prod vin (G239378) stuck today; class of bug
|
|
227
|
-
recurs on every customer name with emoji or non-Latin characters.
|
|
228
|
-
|
|
229
|
-
## Previous direction (historical)
|
|
230
|
-
|
|
231
|
-
This spec previously proposed "do nothing in rupdf; have sklib pre-strip
|
|
232
|
-
chars upstream of layout." That plan was sufficient for emoji-only label
|
|
233
|
-
content but doesn't address the broader CJK / Arabic / Cyrillic case,
|
|
234
|
-
where the right behavior is to swap typefaces rather than drop characters.
|
|
235
|
-
The current direction supersedes it. The sklib-side spec
|
|
236
|
-
(`sklib/.specs/2026-05-12-pdf-missing-glyph-hard-error.md`) has been
|
|
237
|
-
updated to match.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|