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.
Files changed (49) hide show
  1. {rupdf-0.2.0 → rupdf-0.2.2}/.gitignore +5 -1
  2. {rupdf-0.2.0 → rupdf-0.2.2}/CHANGELOG.md +34 -0
  3. {rupdf-0.2.0 → rupdf-0.2.2}/Cargo.lock +1 -1
  4. {rupdf-0.2.0 → rupdf-0.2.2}/Cargo.toml +1 -1
  5. {rupdf-0.2.0 → rupdf-0.2.2}/PKG-INFO +8 -2
  6. {rupdf-0.2.0 → rupdf-0.2.2}/README.md +7 -1
  7. {rupdf-0.2.0 → rupdf-0.2.2}/pyproject.toml +1 -1
  8. rupdf-0.2.2/python/rupdf/__init__.py +13 -0
  9. {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/_rupdf.pyi +3 -0
  10. {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_pdf_structure.py +133 -0
  11. {rupdf-0.2.0 → rupdf-0.2.2}/scripts/generate_test_pdf.py +22 -0
  12. {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/writer.rs +28 -3
  13. {rupdf-0.2.0 → rupdf-0.2.2}/src/resources.rs +61 -0
  14. {rupdf-0.2.0 → rupdf-0.2.2}/src/types.rs +2 -0
  15. rupdf-0.2.0/.specs/2026-05-12-missing-glyph-hard-error.md +0 -237
  16. rupdf-0.2.0/python/rupdf/__init__.py +0 -6
  17. {rupdf-0.2.0 → rupdf-0.2.2}/.claude/settings.local.json +0 -0
  18. {rupdf-0.2.0 → rupdf-0.2.2}/.envrc +0 -0
  19. {rupdf-0.2.0 → rupdf-0.2.2}/CLAUDE.md +0 -0
  20. {rupdf-0.2.0 → rupdf-0.2.2}/LICENSE +0 -0
  21. {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexMono-Bold.otf +0 -0
  22. {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexMono-Regular.otf +0 -0
  23. {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexSans-Bold.otf +0 -0
  24. {rupdf-0.2.0 → rupdf-0.2.2}/assets/IBMPlexSans-Regular.otf +0 -0
  25. {rupdf-0.2.0 → rupdf-0.2.2}/assets/test-png.png +0 -0
  26. {rupdf-0.2.0 → rupdf-0.2.2}/assets/test-svg.svg +0 -0
  27. {rupdf-0.2.0 → rupdf-0.2.2}/benches/render.rs +0 -0
  28. {rupdf-0.2.0 → rupdf-0.2.2}/benchmarks/README.md +0 -0
  29. {rupdf-0.2.0 → rupdf-0.2.2}/benchmarks/run_benchmark.py +0 -0
  30. {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/README.md +0 -0
  31. {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/all_elements.json +0 -0
  32. {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/multi_page.json +0 -0
  33. {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/simple_text.json +0 -0
  34. {rupdf-0.2.0 → rupdf-0.2.2}/fixtures/stress_test.json +0 -0
  35. {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/__init__.pyi +0 -0
  36. {rupdf-0.2.0 → rupdf-0.2.2}/python/rupdf/py.typed +0 -0
  37. {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/__init__.py +0 -0
  38. {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/conftest.py +0 -0
  39. {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_font_fallback.py +0 -0
  40. {rupdf-0.2.0 → rupdf-0.2.2}/python/tests/test_interface.py +0 -0
  41. {rupdf-0.2.0 → rupdf-0.2.2}/scripts/demo_pdf.py +0 -0
  42. {rupdf-0.2.0 → rupdf-0.2.2}/scripts/position_test.py +0 -0
  43. {rupdf-0.2.0 → rupdf-0.2.2}/src/elements/mod.rs +0 -0
  44. {rupdf-0.2.0 → rupdf-0.2.2}/src/elements/svg.rs +0 -0
  45. {rupdf-0.2.0 → rupdf-0.2.2}/src/error.rs +0 -0
  46. {rupdf-0.2.0 → rupdf-0.2.2}/src/lib.rs +0 -0
  47. {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/fonts.rs +0 -0
  48. {rupdf-0.2.0 → rupdf-0.2.2}/src/pdf/mod.rs +0 -0
  49. {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
@@ -511,7 +511,7 @@ dependencies = [
511
511
 
512
512
  [[package]]
513
513
  name = "rupdf"
514
- version = "0.2.0"
514
+ version = "0.2.2"
515
515
  dependencies = [
516
516
  "image 0.24.9",
517
517
  "miniz_oxide 0.6.2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rupdf"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  description = "A fast, minimal PDF page renderer"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rupdf
3
- Version: 0.2.0
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" # "baseline", "capline", or "center" (optional)
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" # "baseline", "capline", or "center" (optional)
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".
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rupdf"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "A fast, minimal PDF page renderer"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 pdf_box_bottom = page_height - box_top - textbox.h;
525
- content.rect(box_left, pdf_box_bottom, textbox.w, textbox.h);
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.
@@ -1,6 +0,0 @@
1
- """rupdf - A fast, minimal PDF page renderer."""
2
-
3
- from rupdf._rupdf import render_pdf, RupdfError
4
-
5
- __all__ = ["render_pdf", "RupdfError"]
6
- __version__ = "0.1.0"
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