stationery 0.11.0 → 0.12.0

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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -68
  3. data/README.md +775 -78
  4. data/examples/accessible_report.rb +133 -0
  5. data/examples/article.rb +93 -0
  6. data/examples/assets/bay.png +0 -0
  7. data/examples/assets/dunes.png +0 -0
  8. data/examples/assets/hills.png +0 -0
  9. data/examples/assets/logo.png +0 -0
  10. data/examples/assets/ridge.png +0 -0
  11. data/examples/assets/sea.png +0 -0
  12. data/examples/assets/stone.png +0 -0
  13. data/examples/assets/wall.png +0 -0
  14. data/examples/certificate.rb +104 -0
  15. data/examples/contract.rb +159 -0
  16. data/examples/e_invoice.rb +161 -0
  17. data/examples/flyer.rb +336 -0
  18. data/examples/form.rb +113 -0
  19. data/examples/invoice.rb +163 -0
  20. data/examples/letter.rb +121 -0
  21. data/examples/menu.rb +133 -0
  22. data/examples/multilingual_notice.rb +121 -0
  23. data/examples/newsletter.rb +108 -0
  24. data/examples/packing_slip.rb +120 -0
  25. data/examples/postcard.rb +83 -0
  26. data/examples/price_list.rb +107 -0
  27. data/examples/receipt.rb +176 -0
  28. data/examples/report.rb +294 -0
  29. data/examples/resume.rb +121 -0
  30. data/examples/shaping/extraction_matrix/pdfium.py +8 -0
  31. data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
  32. data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
  33. data/examples/shaping/extraction_matrix.rb +208 -0
  34. data/examples/shaping/harfbuzz_shaper.rb +132 -0
  35. data/examples/shaping/rtl_letter.rb +106 -0
  36. data/examples/shipping_label.rb +93 -0
  37. data/lib/stationery/barcode/code128.rb +96 -0
  38. data/lib/stationery/barcode/data_matrix.rb +111 -0
  39. data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
  40. data/lib/stationery/barcode/ean13.rb +66 -0
  41. data/lib/stationery/barcode/qr.rb +120 -0
  42. data/lib/stationery/barcode/qr_matrix.rb +189 -0
  43. data/lib/stationery/barcode/reed_solomon.rb +61 -0
  44. data/lib/stationery/barcode.rb +33 -0
  45. data/lib/stationery/builder.rb +12 -2
  46. data/lib/stationery/canvas/glyphs.rb +40 -0
  47. data/lib/stationery/canvas/interface.rb +169 -0
  48. data/lib/stationery/canvas/marking.rb +32 -3
  49. data/lib/stationery/canvas/path.rb +32 -70
  50. data/lib/stationery/canvas/surfacing.rb +67 -0
  51. data/lib/stationery/canvas/text.rb +4 -25
  52. data/lib/stationery/canvas.rb +24 -75
  53. data/lib/stationery/cli/examples.rb +66 -0
  54. data/lib/stationery/cli/inspect.rb +65 -0
  55. data/lib/stationery/cli/layout_text.rb +132 -0
  56. data/lib/stationery/cli/pictures.rb +50 -0
  57. data/lib/stationery/cli/render.rb +33 -6
  58. data/lib/stationery/cli/skill.rb +166 -0
  59. data/lib/stationery/cli.rb +5 -0
  60. data/lib/stationery/color.rb +27 -13
  61. data/lib/stationery/component.rb +1 -0
  62. data/lib/stationery/css/stylesheet.rb +9 -1
  63. data/lib/stationery/document.rb +135 -27
  64. data/lib/stationery/elements/forms.rb +2 -1
  65. data/lib/stationery/elements/lists.rb +6 -2
  66. data/lib/stationery/elements.rb +43 -7
  67. data/lib/stationery/examples.rb +22 -0
  68. data/lib/stationery/fonts/cff.rb +46 -0
  69. data/lib/stationery/fonts/charstring.rb +297 -0
  70. data/lib/stationery/fonts/fallback.rb +22 -1
  71. data/lib/stationery/fonts/font.rb +58 -3
  72. data/lib/stationery/fonts/font_book.rb +7 -3
  73. data/lib/stationery/fonts/glyf_outline.rb +230 -0
  74. data/lib/stationery/fonts/glyph_run.rb +37 -10
  75. data/lib/stationery/fonts/outline.rb +102 -0
  76. data/lib/stationery/fonts/shaped_run.rb +22 -4
  77. data/lib/stationery/fonts/true_type.rb +7 -0
  78. data/lib/stationery/forms/typeface.rb +2 -4
  79. data/lib/stationery/html/css.rb +31 -6
  80. data/lib/stationery/html/tree_builder/blocks.rb +7 -3
  81. data/lib/stationery/images/images.rb +10 -0
  82. data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
  83. data/lib/stationery/images/jpeg/colors.rb +133 -0
  84. data/lib/stationery/images/jpeg/decoder.rb +253 -0
  85. data/lib/stationery/images/jpeg/huffman.rb +59 -0
  86. data/lib/stationery/images/jpeg/idct.rb +150 -0
  87. data/lib/stationery/images/jpeg/progressive.rb +128 -0
  88. data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
  89. data/lib/stationery/images/jpeg/scan.rb +116 -0
  90. data/lib/stationery/images/jpeg/upsampler.rb +81 -0
  91. data/lib/stationery/images/jpeg.rb +59 -1
  92. data/lib/stationery/images/png.rb +10 -7
  93. data/lib/stationery/images/resampled.rb +10 -2
  94. data/lib/stationery/images/webp.rb +6 -5
  95. data/lib/stationery/layout/barcode.rb +75 -0
  96. data/lib/stationery/layout/box/wrapping.rb +56 -0
  97. data/lib/stationery/layout/box.rb +41 -15
  98. data/lib/stationery/layout/columns/leveller.rb +65 -0
  99. data/lib/stationery/layout/columns/pour.rb +7 -0
  100. data/lib/stationery/layout/columns.rb +32 -15
  101. data/lib/stationery/layout/field.rb +3 -2
  102. data/lib/stationery/layout/floated.rb +12 -1
  103. data/lib/stationery/layout/flow/floating.rb +23 -5
  104. data/lib/stationery/layout/flow/placement.rb +16 -12
  105. data/lib/stationery/layout/flow/splitter.rb +56 -10
  106. data/lib/stationery/layout/flow.rb +1 -0
  107. data/lib/stationery/layout/image.rb +2 -2
  108. data/lib/stationery/layout/list_item.rb +41 -11
  109. data/lib/stationery/layout/mark.rb +2 -0
  110. data/lib/stationery/layout/node.rb +16 -2
  111. data/lib/stationery/layout/opening.rb +61 -0
  112. data/lib/stationery/layout/paginator.rb +24 -5
  113. data/lib/stationery/layout/svg.rb +1 -1
  114. data/lib/stationery/layout/table/cell.rb +39 -10
  115. data/lib/stationery/layout/table/row_splitter.rb +6 -2
  116. data/lib/stationery/layout/table/selection.rb +9 -3
  117. data/lib/stationery/layout/table/stream.rb +123 -0
  118. data/lib/stationery/layout/table/widths.rb +26 -0
  119. data/lib/stationery/layout/table.rb +151 -35
  120. data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
  121. data/lib/stationery/layout/table_of_contents.rb +1 -1
  122. data/lib/stationery/layout/text.rb +22 -2
  123. data/lib/stationery/minitest.rb +11 -3
  124. data/lib/stationery/monochrome/bitmap.rb +78 -0
  125. data/lib/stationery/monochrome/canvas.rb +19 -0
  126. data/lib/stationery/monochrome/dither.rb +74 -0
  127. data/lib/stationery/monochrome/grid.rb +131 -0
  128. data/lib/stationery/monochrome/painting.rb +140 -0
  129. data/lib/stationery/monochrome/rules.rb +141 -0
  130. data/lib/stationery/monochrome.rb +87 -0
  131. data/lib/stationery/page/format.rb +84 -0
  132. data/lib/stationery/page.rb +11 -7
  133. data/lib/stationery/page_templates.rb +13 -10
  134. data/lib/stationery/path.rb +138 -0
  135. data/lib/stationery/pdf/assembler.rb +14 -3
  136. data/lib/stationery/pdf/canvases.rb +51 -0
  137. data/lib/stationery/pdf/conformance.rb +43 -13
  138. data/lib/stationery/pdf/print_hints.rb +137 -0
  139. data/lib/stationery/pdf/serializer.rb +41 -0
  140. data/lib/stationery/raster/adjust.rb +67 -0
  141. data/lib/stationery/raster/canvas.rb +108 -0
  142. data/lib/stationery/raster/canvases.rb +49 -0
  143. data/lib/stationery/raster/flattener.rb +75 -0
  144. data/lib/stationery/raster/glyphs.rb +81 -0
  145. data/lib/stationery/raster/painter.rb +184 -0
  146. data/lib/stationery/raster/picture.rb +69 -0
  147. data/lib/stationery/raster/png.rb +32 -0
  148. data/lib/stationery/raster/render.rb +134 -0
  149. data/lib/stationery/raster/scanner.rb +164 -0
  150. data/lib/stationery/raster/shading.rb +77 -0
  151. data/lib/stationery/raster/spans.rb +66 -0
  152. data/lib/stationery/raster/stroker.rb +208 -0
  153. data/lib/stationery/raster/surface.rb +70 -0
  154. data/lib/stationery/raster.rb +44 -0
  155. data/lib/stationery/rich/nodes.rb +4 -4
  156. data/lib/stationery/rich/renderer/boxes.rb +37 -16
  157. data/lib/stationery/rich/renderer.rb +3 -2
  158. data/lib/stationery/skill/SKILL.md.erb +244 -0
  159. data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
  160. data/lib/stationery/skill/recipes/form.md.erb +78 -0
  161. data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
  162. data/lib/stationery/skill/recipes/label.md.erb +116 -0
  163. data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
  164. data/lib/stationery/skill/recipes/report.md.erb +113 -0
  165. data/lib/stationery/skill/reference/conformance.md.erb +45 -0
  166. data/lib/stationery/skill/reference/elements.md.erb +19 -0
  167. data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
  168. data/lib/stationery/skill/reference/forms.md.erb +5 -0
  169. data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
  170. data/lib/stationery/skill/reference/pages.md.erb +15 -0
  171. data/lib/stationery/skill/reference/printing.md.erb +33 -0
  172. data/lib/stationery/skill/reference/testing.md.erb +33 -0
  173. data/lib/stationery/skill.rb +101 -0
  174. data/lib/stationery/structure.rb +7 -6
  175. data/lib/stationery/svg/gradient.rb +12 -0
  176. data/lib/stationery/svg/painter.rb +1 -1
  177. data/lib/stationery/tagging/element.rb +9 -2
  178. data/lib/stationery/tagging/tree.rb +98 -2
  179. data/lib/stationery/testing/field_page.rb +89 -0
  180. data/lib/stationery/testing/inspector.rb +80 -2
  181. data/lib/stationery/testing/layout.rb +166 -0
  182. data/lib/stationery/testing/marked_text.rb +124 -18
  183. data/lib/stationery/testing/matchers.rb +63 -14
  184. data/lib/stationery/testing/page_reader.rb +146 -0
  185. data/lib/stationery/text/exclusions.rb +11 -0
  186. data/lib/stationery/text/paragraph.rb +13 -8
  187. data/lib/stationery/text/wrapper/one_word.rb +33 -0
  188. data/lib/stationery/text/wrapper.rb +62 -27
  189. data/lib/stationery/units.rb +45 -0
  190. data/lib/stationery/version.rb +1 -1
  191. data/lib/stationery/warnings.rb +67 -7
  192. data/lib/stationery/zpl/native.rb +51 -0
  193. data/lib/stationery/zpl/render.rb +56 -0
  194. data/lib/stationery/zpl.rb +88 -0
  195. data/lib/stationery.rb +48 -0
  196. metadata +118 -1
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A one-page résumé in two columns of unequal width: a tinted sidebar with
4
+ # contact links and skills, and the experience written as HTML, the way it
5
+ # comes out of a rich-text editor. Run it to write examples/resume.pdf:
6
+ #
7
+ # ruby -Ilib examples/resume.rb
8
+ # ruby -Ilib exe/stationery render examples/resume.rb
9
+ require "stationery"
10
+
11
+ class ExampleResume < Stationery::Document
12
+ ACCENT = "#2563EB"
13
+ INK = "#111827"
14
+ MUTED = "#6B7280"
15
+ SIDEBAR = "#F1F5F9"
16
+
17
+ # What the person wrote in the application's editor: `html` reads it with
18
+ # the styles given below, and turns its links into links.
19
+ EXPERIENCE = <<~HTML
20
+ <style>.when { color: #6B7280; font-size: 8.5pt }</style>
21
+ <h2>Profile</h2>
22
+ <p>Engineer who likes the unglamorous parts of logistics software: the queues, the retries and the
23
+ documents at the end of them. Ten years of Ruby, most of them with a pager.</p>
24
+ <h2>Experience</h2>
25
+ <h3>Lead engineer · Harbour Logistics, Gothenburg</h3>
26
+ <p class="when">2022 – today</p>
27
+ <p>Leads a team of six building the routing platform that plans 40,000 deliveries a day.</p>
28
+ <ul>
29
+ <li>Moved route planning from a nightly batch to <b>live replanning</b>, cutting late deliveries by a third.</li>
30
+ <li>Introduced contract tests between the eleven services the platform is made of.</li>
31
+ <li>Mentors two engineers a year through the company's <a href="https://harbour-logistics.example/graduates">graduate programme</a>.</li>
32
+ </ul>
33
+ <h3>Software engineer · Northwind Freight, Malmö</h3>
34
+ <p class="when">2018 – 2022</p>
35
+ <p>Built the customer portal and the <i>proof of delivery</i> service, from the first commit to
36
+ two million documents a month.</p>
37
+ <ul>
38
+ <li>Replaced a PDF service in Java with one in Ruby that renders in a tenth of the time.</li>
39
+ <li>Ran the on-call rotation for the portal and wrote its runbooks.</li>
40
+ </ul>
41
+ <h3>Junior developer · Example Studio, Lund</h3>
42
+ <p class="when">2016 – 2018</p>
43
+ <p>Web shops and booking systems for small businesses in southern Sweden.</p>
44
+ <h2>Education</h2>
45
+ <h3>MSc Computer Science · Lund University</h3>
46
+ <p class="when">2011 – 2016</p>
47
+ <p>Thesis on incremental layout of long documents, graded with distinction.</p>
48
+ HTML
49
+
50
+ STYLES = {
51
+ h2: { size: 13, color: ACCENT }, h3: { size: 10.5 }, p: { color: INK },
52
+ a: { color: ACCENT }, ul: { gap: 3, marker_color: ACCENT }
53
+ }.freeze
54
+
55
+ page size: :a4, margin: [44, 44, 40, 44]
56
+ default_text size: 9.5, color: INK, leading: 2
57
+ metadata title: "Résumé of Kim Example", author: "Kim Example", creator: "stationery example"
58
+
59
+ def self.preview
60
+ new(name: "Kim Example", title: "Lead software engineer",
61
+ contact: [["kim@example.com", "mailto:kim@example.com"], ["+46 70 000 00 00", "tel:+46700000000"],
62
+ ["kim.example.com", "https://kim.example.com"], ["Gothenburg, Sweden", nil]],
63
+ skills: %w[Ruby Rails PostgreSQL Kafka Kubernetes Terraform TypeScript],
64
+ languages: [%w[Swedish native], %w[English fluent], %w[German good]])
65
+ end
66
+
67
+ def initialize(name:, title:, contact:, skills:, languages:)
68
+ super()
69
+ @name = name
70
+ @title = title
71
+ @contact = contact
72
+ @skills = skills
73
+ @languages = languages
74
+ end
75
+
76
+ def view_template
77
+ text @name, size: 28, weight: :bold
78
+ text @title, size: 12, color: ACCENT
79
+ spacer 18
80
+ # A sidebar of 30 % and a body that takes the rest: `width:` is a share
81
+ # of the row, and a column without one takes what is left.
82
+ row(gap: 24) do
83
+ column(width: 0.3) { sidebar }
84
+ column { html EXPERIENCE, styles: STYLES, gap: 5 }
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def sidebar
91
+ box(background: SIDEBAR, radius: 6, padding: 14) do
92
+ label "CONTACT"
93
+ @contact.each do |shown, target|
94
+ text shown, link: target, color: target ? ACCENT : INK, size: 9
95
+ spacer 2
96
+ end
97
+ spacer 12
98
+ label "SKILLS"
99
+ wrap(gap: 4, row_gap: 4) do
100
+ @skills.each do |skill|
101
+ box(width: :auto, background: "#FFFFFF", radius: 8, padding: [2, 7]) { text skill, size: 8 }
102
+ end
103
+ end
104
+ spacer 12
105
+ label "LANGUAGES"
106
+ @languages.each { |language, level| text "<b>#{language}</b>, #{level}", markup: true, size: 9 }
107
+ end
108
+ end
109
+
110
+ def label(title)
111
+ text title, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.8
112
+ spacer 5
113
+ end
114
+ end
115
+
116
+ if $PROGRAM_NAME == __FILE__
117
+ resume = ExampleResume.preview
118
+ resume.to_pdf(File.expand_path("resume.pdf", __dir__))
119
+ warn resume.warnings.map(&:message) if resume.warnings.any?
120
+ puts "wrote examples/resume.pdf"
121
+ end
@@ -0,0 +1,8 @@
1
+ # The text of a PDF as PDFium reads it (FPDFText_GetText, what Chrome copies): python pdfium.py file.pdf
2
+ import sys
3
+
4
+ import pypdfium2
5
+
6
+ document = pypdfium2.PdfDocument(sys.argv[1])
7
+ for page in document:
8
+ sys.stdout.write(page.get_textpage().get_text_range().replace("\r\n", "\n") + "\n")
@@ -0,0 +1,18 @@
1
+ // The text of a PDF as pdf.js reads it: node pdfjs.mjs path/to/node_modules/pdfjs-dist file.pdf
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const [root, file] = process.argv.slice(2);
7
+ const { getDocument } = await import(pathToFileURL(join(root, "legacy/build/pdf.mjs")).href);
8
+ const document = await getDocument({
9
+ data: new Uint8Array(readFileSync(file)),
10
+ standardFontDataUrl: join(root, "standard_fonts/"),
11
+ verbosity: 0,
12
+ }).promise;
13
+
14
+ for (let number = 1; number <= document.numPages; number++) {
15
+ const page = await document.getPage(number);
16
+ const content = await page.getTextContent();
17
+ process.stdout.write(content.items.map((item) => item.str + (item.hasEOL ? "\n" : "")).join("") + "\n");
18
+ }
@@ -0,0 +1,6 @@
1
+ // The text of a PDF as PDFKit reads it, which is what Preview copies: swift pdfkit.swift file.pdf
2
+ import Foundation
3
+ import PDFKit
4
+
5
+ guard let document = PDFDocument(url: URL(fileURLWithPath: CommandLine.arguments[1])) else { exit(1) }
6
+ print(document.string ?? "")
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What comes out of a PDF whose right-to-left text a shaper placed, extractor
4
+ # by extractor, for three ways of writing it (zoolutions/stationery#151):
5
+ #
6
+ # A what the gem writes: a reordered stretch in one Span whose ActualText
7
+ # is the whole stretch in logical order
8
+ # B one Span per cluster, in visual order
9
+ # C no Span where the ToUnicode map gives a glyph its text; one per
10
+ # cluster where it cannot
11
+ #
12
+ # B and C are experiments that live in this file only (Variants, below): the
13
+ # gem writes A. Nothing here is loaded by the gem.
14
+ #
15
+ # Needs, all outside the gem's bundle:
16
+ #
17
+ # brew install harfbuzz poppler mupdf
18
+ # gem install harfbuzz-ruby pdf-reader
19
+ # npm install pdfjs-dist (in a directory of its own)
20
+ # python3 -m venv venv && venv/bin/pip install pypdfium2
21
+ # NotoSansArabic-Regular.ttf and NotoSansHebrew-Regular.ttf, from
22
+ # https://github.com/notofonts/notofonts.github.io/tree/main/fonts
23
+ #
24
+ # The Noto Arabic families draw a letter as two glyphs, its shape and its
25
+ # dots, so no ToUnicode map can give their text. Amiri
26
+ # (https://github.com/aliftype/amiri) draws one glyph per letter: run the
27
+ # matrix with both.
28
+ #
29
+ # Run:
30
+ #
31
+ # ruby -Ilib examples/shaping/extraction_matrix.rb \
32
+ # --arabic NotoSansArabic-Regular.ttf --hebrew NotoSansHebrew-Regular.ttf \
33
+ # --out tmp/extraction [--pdfjs dir/node_modules/pdfjs-dist] [--python venv/bin/python]
34
+ #
35
+ # It writes <out>/<variant>-<case>.pdf, the same tagged as PDF/UA-1
36
+ # (<out>/tagged/, for veraPDF), and <out>/matrix.md. An extractor that is not
37
+ # installed is left out of the matrix, never guessed. PDFKit (what Preview
38
+ # reads with) runs on macOS through swift.
39
+ require "optparse"
40
+ require "open3"
41
+ require "fileutils"
42
+ require_relative "harfbuzz_shaper"
43
+ require "stationery/testing/inspector"
44
+
45
+ module ExtractionMatrix
46
+ HERE = File.expand_path("extraction_matrix", __dir__)
47
+ CONTROLS = /[‎‏‪-‮⁦-⁩]/
48
+
49
+ # The variant being rendered, :a unless #render says otherwise.
50
+ def self.variant = Thread.current[:extraction_matrix_variant] || :a
51
+
52
+ # The experiments, over Stationery::Fonts::ShapedRun#to_operator.
53
+ module Variants
54
+ def to_operator
55
+ return super if ExtractionMatrix.variant == :a
56
+
57
+ groups = pieces.chunk_while { |(left, _), (right, _)| left && right }
58
+ .map { |group| [group.first.first, group.flat_map(&:last)] }
59
+ groups.each_with_index.map do |(plain, indices), index|
60
+ trailing = index < groups.size - 1
61
+ plain ? show(indices, trailing) : marked(indices, trailing)
62
+ end.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ # [[plain, glyph indexes], …], one per cluster in visual order.
68
+ def pieces
69
+ counts = glyphs.map(&:cluster).tally
70
+ own = glyphs.map { |glyph| own?(glyph, counts[glyph.cluster] == 1) }
71
+ return clusters(glyphs.each_index.to_a, own) if ExtractionMatrix.variant == :c
72
+
73
+ # B: the stretches A cuts, each reordered one a Span per cluster.
74
+ stretches.flat_map { |plain, indices| plain ? [[true, indices]] : clusters(indices, own, spans: true) }
75
+ end
76
+
77
+ def clusters(indices, own, spans: false)
78
+ indices.chunk_while { |left, right| glyphs[left].cluster == glyphs[right].cluster }
79
+ .map { |cluster| [!spans && cluster.size == 1 && own[cluster.first], cluster] }
80
+ end
81
+ end
82
+
83
+ Case = Data.define(:name, :lang, :text, :options)
84
+
85
+ CASES = [
86
+ Case.new("arabic", "ar", "فاتورة ضريبية", { align: :right }),
87
+ Case.new("hebrew", "he", "שלום עולם", { align: :right }),
88
+ Case.new("digits", "ar", "فاتورة رقم 2024 بقيمة", { align: :right }),
89
+ Case.new("marks", "ar", "السَّلَامُ عَلَيْكُمْ", { align: :right }),
90
+ # A space after the Arabic word would be shaped with it and drawn on its
91
+ # left, before it: stretches are not reordered (see the README).
92
+ Case.new("in-latin", "en", "Pay the فاتورة, please.", {}),
93
+ Case.new("justified", "ar", "هذه فقرة طويلة مكتوبة باللغة العربية لتجربة ضبط السطور من اليمين إلى اليسار " \
94
+ "في مستند واحد يحتوي على أكثر من سطر", { align: :justify }),
95
+ Case.new("wraps", "ar", "مرحبا بالعالم هذه فاتورة ضريبية جديدة من الشركة", { align: :right, size: 20 })
96
+ ].freeze
97
+
98
+ Extractor = Data.define(:name, :command)
99
+
100
+ module_function
101
+
102
+ def document(kase, fonts)
103
+ Class.new(Stationery::Document) do
104
+ page size: [298, 420], margin: 36
105
+ metadata title: kase.name, lang: kase.lang
106
+ shaper HarfBuzzShaper.new
107
+ font_family "Arabic", regular: fonts.fetch(:arabic)
108
+ font_family "Hebrew", regular: fonts.fetch(:hebrew)
109
+ font_fallbacks "Arabic", "Hebrew"
110
+ default_text font: { "ar" => "Arabic", "he" => "Hebrew" }.fetch(kase.lang, "Inter"), size: 14
111
+ define_method(:view_template) { text kase.text, **kase.options }
112
+ end
113
+ end
114
+
115
+ def extractors(options)
116
+ [
117
+ Extractor.new("poppler (pdftotext)", %w[pdftotext -enc UTF-8 %s -]),
118
+ Extractor.new("MuPDF (mutool)", %w[mutool draw -q -F txt %s]),
119
+ Extractor.new("PDFium (pypdfium2)", [options[:python], File.join(HERE, "pdfium.py"), "%s"]),
120
+ Extractor.new("pdf.js (pdfjs-dist)", ["node", File.join(HERE, "pdfjs.mjs"), options[:pdfjs], "%s"]),
121
+ Extractor.new("PDFKit (macOS)", ["swift", File.join(HERE, "pdfkit.swift"), "%s"])
122
+ ].select { |extractor| runs?(extractor) }
123
+ end
124
+
125
+ def runs?(extractor)
126
+ extractor.command.none?(&:nil?) && system("which", extractor.command.first, out: File::NULL, err: File::NULL)
127
+ end
128
+
129
+ def extract(extractor, path)
130
+ out, status = Open3.capture2(*extractor.command.map { |part| part == "%s" ? path : part }, err: File::NULL)
131
+ status.success? ? out.force_encoding(Encoding::UTF_8) : nil
132
+ end
133
+
134
+ def lines(text)
135
+ text.unicode_normalize(:nfc).split(/[\n\f]+/).map { |line| line.squeeze(" ").strip }.reject(&:empty?)
136
+ end
137
+
138
+ # "as written", "reversed" (every line back to front), or what came out.
139
+ def verdict(expected, text)
140
+ return "failed" unless text
141
+
142
+ found = lines(text)
143
+ wanted = expected.unicode_normalize(:nfc)
144
+ controls = found.join.match?(CONTROLS) ? ", with bidi controls" : ""
145
+ found = found.map { |line| line.gsub(CONTROLS, "") }
146
+ return "as written#{controls}" if found.join(" ") == wanted
147
+ return "reversed#{controls}" if found.map(&:reverse).join(" ") == wanted
148
+
149
+ "other#{controls}: `#{found.join(" ⏎ ")}`"
150
+ end
151
+
152
+ def render(options)
153
+ FileUtils.mkdir_p(File.join(options[:out], "tagged"))
154
+ %i[a b c].flat_map do |variant|
155
+ Thread.current[:extraction_matrix_variant] = variant
156
+ CASES.map do |kase|
157
+ path = File.join(options[:out], "#{variant}-#{kase.name}.pdf")
158
+ document(kase, options).new.to_pdf(path)
159
+ document(kase, options).new.to_pdf(File.join(options[:out], "tagged", File.basename(path)),
160
+ conformance: :pdf_ua1)
161
+ [variant, kase, path]
162
+ end
163
+ end
164
+ end
165
+
166
+ def matrix(options)
167
+ renders = render(options)
168
+ readers = extractors(options)
169
+ rows = renders.map do |variant, kase, path|
170
+ inspector = Stationery::Testing::Inspector.new(File.binread(path)).text
171
+ [variant.to_s.upcase, kase.name, verdict(kase.text, inspector)] +
172
+ readers.map { |reader| verdict(kase.text, extract(reader, path)) }
173
+ end
174
+ names = ["Inspector"] + readers.map(&:name)
175
+ [table(["Variant", "Case", *names], rows), table(["Variant", *names], scores(rows))].join("\n\n")
176
+ end
177
+
178
+ # The cases each reader returns as written, per variant.
179
+ def scores(rows)
180
+ rows.group_by(&:first).map do |variant, cases|
181
+ [variant] + cases.map { |row| row.drop(2) }.transpose.map do |cells|
182
+ "#{cells.count { |cell| cell.start_with?("as written") }} of #{cells.size}"
183
+ end
184
+ end
185
+ end
186
+
187
+ def table(names, rows)
188
+ ["| #{names.join(" | ")} |", "|#{"---|" * names.size}", *rows.map { |row| "| #{row.join(" | ")} |" }].join("\n")
189
+ end
190
+ end
191
+
192
+ Stationery::Fonts::ShapedRun.prepend(ExtractionMatrix::Variants)
193
+
194
+ if $PROGRAM_NAME == __FILE__
195
+ options = { out: "tmp/extraction", python: nil, pdfjs: nil }
196
+ OptionParser.new do |parser|
197
+ parser.on("--arabic PATH") { |path| options[:arabic] = File.expand_path(path) }
198
+ parser.on("--hebrew PATH") { |path| options[:hebrew] = File.expand_path(path) }
199
+ parser.on("--out DIR") { |dir| options[:out] = File.expand_path(dir) }
200
+ parser.on("--pdfjs DIR") { |dir| options[:pdfjs] = File.expand_path(dir) }
201
+ parser.on("--python PATH") { |path| options[:python] = File.expand_path(path) }
202
+ end.parse!
203
+ abort "usage: see the head of #{__FILE__}" unless options[:arabic] && options[:hebrew]
204
+
205
+ table = ExtractionMatrix.matrix(options)
206
+ File.write(File.join(options[:out], "matrix.md"), "#{table}\n")
207
+ puts table
208
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A shaper for Stationery::Shaper built on HarfBuzz, through the harfbuzz-ruby
4
+ # gem (https://github.com/ydah/harfbuzz, `require "harfbuzz"`). Stationery
5
+ # does not depend on it: copy this file into the application that needs
6
+ # Arabic, Hebrew, Indic or Thai text and add to its Gemfile
7
+ #
8
+ # gem "harfbuzz-ruby"
9
+ #
10
+ # with the HarfBuzz library installed (`brew install harfbuzz`,
11
+ # `apt-get install libharfbuzz-dev`). Then
12
+ #
13
+ # class Invoice < Stationery::Document
14
+ # shaper HarfBuzzShaper.new
15
+ # font_family "Noto Sans Arabic", regular: "NotoSansArabic-Regular.ttf"
16
+ # end
17
+ #
18
+ # Run it to write examples/shaping/harfbuzz_shaper.pdf with a font that has
19
+ # the Arabic script:
20
+ #
21
+ # ruby -Ilib examples/shaping/harfbuzz_shaper.rb NotoSansArabic-Regular.ttf
22
+ #
23
+ # What it does not do: the Unicode bidirectional algorithm. A stretch of text
24
+ # is cut into runs of one direction by its letters alone (digits read left to
25
+ # right, spaces and punctuation go with their neighbours), which places
26
+ # "فاتورة 2024" and "Total: ٤٠ ريال" as a reader expects and gets nested
27
+ # quotations, brackets and explicit embeddings wrong. An application that
28
+ # needs those resolves the levels itself and shapes each run as #shape does.
29
+ require "harfbuzz"
30
+ require "stationery"
31
+
32
+ class HarfBuzzShaper
33
+ RIGHT_TO_LEFT = /[\p{Arabic}\p{Hebrew}\p{Syriac}\p{Thaana}\p{Nko}]/
34
+ STRONG = /[\p{L}\p{Nd}]/
35
+ Run = Data.define(:direction, :start, :length)
36
+
37
+ def initialize
38
+ @fonts = {}
39
+ @mutex = Mutex.new
40
+ end
41
+
42
+ # The glyphs of `text` in visual order; see Stationery::Shaper.
43
+ def call(text, font, features:, language: nil, **)
44
+ hb_font = font_for(font)
45
+ switches = HarfBuzz::Feature.from_hash(features)
46
+ codepoints = text.codepoints
47
+ runs(text).flat_map { |run| shape(hb_font, codepoints, run, switches, language) }
48
+ end
49
+
50
+ private
51
+
52
+ # One HarfBuzz font per font file and face, kept between renders. The
53
+ # default scale of a HarfBuzz font is its units per em, so what it answers
54
+ # is in font units, as stationery asks.
55
+ def font_for(font)
56
+ @mutex.synchronize do
57
+ @fonts[[font.path || font.postscript_name, font.index]] ||=
58
+ HarfBuzz::Font.new(HarfBuzz::Face.new(HarfBuzz::Blob.new(font.data), font.index))
59
+ end
60
+ end
61
+
62
+ # The whole text is given to HarfBuzz with the run marked in it, so a run
63
+ # is shaped in its context and the clusters count characters of the text.
64
+ def shape(font, codepoints, run, features, language)
65
+ buffer = HarfBuzz::Buffer.new
66
+ buffer.add_codepoints(codepoints, item_offset: run.start, item_length: run.length)
67
+ buffer.direction = run.direction
68
+ buffer.language = HarfBuzz.language(language) if language
69
+ buffer.guess_segment_properties # the script, from the text
70
+ HarfBuzz.shape(font, buffer, features)
71
+ glyphs = []
72
+ buffer.each_glyph do |gid, cluster, x_advance, _y_advance, x_offset, y_offset|
73
+ glyphs << Stationery::Shaper::Glyph.new(gid:, cluster:, advance: x_advance, x_offset:, y_offset:)
74
+ end
75
+ glyphs
76
+ end
77
+
78
+ # The runs of one direction in visual order: from the right when the first
79
+ # letter of the text is right to left. Digits do not decide it, so a line
80
+ # of an Arabic paragraph that starts with a number stays right to left.
81
+ def runs(text)
82
+ base = RIGHT_TO_LEFT.match?(text[/\p{L}/].to_s) ? :rtl : :ltr
83
+ directions = resolve(text.each_char.map { |char| direction_of(char) }, base)
84
+ start = 0
85
+ runs = directions.chunk_while { |left, right| left == right }.map do |run|
86
+ Run.new(run.first, start, run.size).tap { start += run.size }
87
+ end
88
+ base == :rtl ? runs.reverse : runs
89
+ end
90
+
91
+ def direction_of(char)
92
+ return unless STRONG.match?(char)
93
+
94
+ RIGHT_TO_LEFT.match?(char) && !char.match?(/\p{Nd}/) ? :rtl : :ltr
95
+ end
96
+
97
+ # Spaces and punctuation take the direction both their neighbours have,
98
+ # else the base direction.
99
+ def resolve(directions, base)
100
+ directions.each_with_index.map do |direction, index|
101
+ next direction if direction
102
+
103
+ before = directions[0...index].compact.last || base
104
+ after = directions[(index + 1)..].compact.first || base
105
+ before == after ? before : base
106
+ end
107
+ end
108
+ end
109
+
110
+ if $PROGRAM_NAME == __FILE__
111
+ font = ARGV.fetch(0) { abort "usage: ruby -Ilib #{__FILE__} path/to/NotoSansArabic-Regular.ttf" }
112
+
113
+ class ExampleShaping < Stationery::Document
114
+ page size: :a5, margin: 36
115
+ metadata title: "فاتورة", lang: "ar"
116
+ shaper HarfBuzzShaper.new
117
+
118
+ def view_template
119
+ text "فاتورة ضريبية", size: 22, align: :right
120
+ text "مرحبا بالعالم، هذه فاتورة رقم 2024 بقيمة ٤٠ ريال.", size: 14, align: :right
121
+ text "السَّلَامُ عَلَيْكُمْ", size: 18, align: :right
122
+ end
123
+ end
124
+
125
+ ExampleShaping.font_family "Arabic", regular: font
126
+ ExampleShaping.default_text font: "Arabic"
127
+ out = File.expand_path("harfbuzz_shaper.pdf", __dir__)
128
+ document = ExampleShaping.new
129
+ document.to_pdf(out)
130
+ document.warnings.each { |warning| warn warning.message }
131
+ puts "wrote #{out}"
132
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A letter in Arabic, right to left, shaped by HarfBuzz through the shaper
4
+ # hook: joined letters, and numbers kept in reading order inside right-to-left
5
+ # lines. Stationery does not shape text itself, so this one needs what the
6
+ # gem does not ship: the
7
+ # harfbuzz-ruby gem with the HarfBuzz library (see harfbuzz_shaper.rb beside
8
+ # it) and a font with the Arabic script, named by ARABIC_FONT:
9
+ #
10
+ # gem install harfbuzz-ruby # with `brew install harfbuzz` or `apt-get install libharfbuzz-dev`
11
+ # ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib examples/shaping/rtl_letter.rb
12
+ # ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib exe/stationery render examples/shaping/rtl_letter.rb
13
+ #
14
+ # It is left out of `rake examples`, the docs site and CI, which have neither.
15
+ begin
16
+ require_relative "harfbuzz_shaper"
17
+ rescue LoadError => e
18
+ abort "examples/shaping/rtl_letter.rb needs the harfbuzz-ruby gem and the HarfBuzz library " \
19
+ "(gem install harfbuzz-ruby): #{e.message}"
20
+ end
21
+ require "stationery"
22
+
23
+ class ExampleRtlLetter < Stationery::Document
24
+ FONT = ENV.fetch("ARABIC_FONT") do
25
+ abort "set ARABIC_FONT to a font with the Arabic script, e.g. ARABIC_FONT=NotoNaskhArabic-Regular.ttf"
26
+ end
27
+ ACCENT = "#065F46"
28
+ INK = "#111827"
29
+ MUTED = "#6B7280"
30
+
31
+ page size: :a4, margin: [64, 64, 56, 64]
32
+ metadata title: "رسالة تأكيد الحجز", author: "Example Travel", lang: "ar", creator: "stationery example"
33
+
34
+ # Every stretch of text goes through the shaper, which answers glyphs in
35
+ # visual order; the Arabic font draws what it has, Inter the rest.
36
+ shaper HarfBuzzShaper.new
37
+ font_family "Arabic", regular: FONT
38
+ font_fallbacks "Inter"
39
+ default_text font: "Arabic", size: 13, color: INK, align: :right, leading: 6
40
+
41
+ footer do
42
+ rule height: 0.5, color: "#D1D5DB"
43
+ spacer 6
44
+ text "Example Travel · booking@travel.example · +971 4 000 0000", size: 9, color: MUTED, align: :center
45
+ end
46
+
47
+ def self.preview
48
+ new(name: "سارة", booking: "EX-2026-7781", hotel: "فندق الواحة", nights: "٣", total: "٢٤٠٠ درهم")
49
+ end
50
+
51
+ def initialize(name:, booking:, hotel:, nights:, total:)
52
+ super()
53
+ @name = name
54
+ @booking = booking
55
+ @hotel = hotel
56
+ @nights = nights
57
+ @total = total
58
+ end
59
+
60
+ def view_template
61
+ row(align: :bottom) do
62
+ column { text "٢٩ سبتمبر ٢٠٢٦", size: 11, color: MUTED, align: :left }
63
+ column { text "Example Travel", size: 20, color: ACCENT }
64
+ end
65
+ spacer 10
66
+ rule height: 2, color: ACCENT
67
+ spacer 24
68
+ text "تأكيد الحجز", size: 22, color: ACCENT
69
+ spacer 14
70
+ text "عزيزتي #{@name}،"
71
+ spacer 8
72
+ # Arabic with Arabic-Indic digits is one stretch of one font, which the
73
+ # shaper orders right to left, across as many lines as it takes.
74
+ text "يسعدنا أن نؤكد حجزك في #{@hotel} لمدة #{@nights} ليالٍ، من ١٢ إلى ١٥ أكتوبر ٢٠٢٦. " \
75
+ "المبلغ الإجمالي #{@total} شاملاً الضرائب والإفطار. يمكنك إلغاء الحجز مجاناً حتى ٤٨ ساعة قبل الوصول."
76
+ spacer 14
77
+ booking
78
+ spacer 14
79
+ text "يرجى ذكر رقم الحجز في كل مراسلاتك معنا. نتمنى لك إقامة سعيدة."
80
+ spacer 30
81
+ text "مع أطيب التحيات،"
82
+ text "فريق السفر", color: ACCENT
83
+ end
84
+
85
+ private
86
+
87
+ # Latin text drawn from Inter is a stretch of its own, and stretches are
88
+ # placed left to right in the order written (the hook has no bidi across
89
+ # fonts): a line that mixes the two directions is built from columns, the
90
+ # first on the right.
91
+ def booking
92
+ box(background: "#ECFDF5", radius: 6, padding: [10, 14]) do
93
+ row(align: :middle) do
94
+ column { text @booking, font: "Inter", size: 14, align: :left, color: ACCENT }
95
+ column { text "رقم الحجز", size: 12, color: MUTED }
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ if $PROGRAM_NAME == __FILE__
102
+ letter = ExampleRtlLetter.preview
103
+ letter.to_pdf(File.expand_path("rtl_letter.pdf", __dir__))
104
+ warn letter.warnings.map(&:message) if letter.warnings.any?
105
+ puts "wrote examples/shaping/rtl_letter.pdf"
106
+ end