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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -68
- data/README.md +775 -78
- data/examples/accessible_report.rb +133 -0
- data/examples/article.rb +93 -0
- data/examples/assets/bay.png +0 -0
- data/examples/assets/dunes.png +0 -0
- data/examples/assets/hills.png +0 -0
- data/examples/assets/logo.png +0 -0
- data/examples/assets/ridge.png +0 -0
- data/examples/assets/sea.png +0 -0
- data/examples/assets/stone.png +0 -0
- data/examples/assets/wall.png +0 -0
- data/examples/certificate.rb +104 -0
- data/examples/contract.rb +159 -0
- data/examples/e_invoice.rb +161 -0
- data/examples/flyer.rb +336 -0
- data/examples/form.rb +113 -0
- data/examples/invoice.rb +163 -0
- data/examples/letter.rb +121 -0
- data/examples/menu.rb +133 -0
- data/examples/multilingual_notice.rb +121 -0
- data/examples/newsletter.rb +108 -0
- data/examples/packing_slip.rb +120 -0
- data/examples/postcard.rb +83 -0
- data/examples/price_list.rb +107 -0
- data/examples/receipt.rb +176 -0
- data/examples/report.rb +294 -0
- data/examples/resume.rb +121 -0
- data/examples/shaping/extraction_matrix/pdfium.py +8 -0
- data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
- data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
- data/examples/shaping/extraction_matrix.rb +208 -0
- data/examples/shaping/harfbuzz_shaper.rb +132 -0
- data/examples/shaping/rtl_letter.rb +106 -0
- data/examples/shipping_label.rb +93 -0
- data/lib/stationery/barcode/code128.rb +96 -0
- data/lib/stationery/barcode/data_matrix.rb +111 -0
- data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
- data/lib/stationery/barcode/ean13.rb +66 -0
- data/lib/stationery/barcode/qr.rb +120 -0
- data/lib/stationery/barcode/qr_matrix.rb +189 -0
- data/lib/stationery/barcode/reed_solomon.rb +61 -0
- data/lib/stationery/barcode.rb +33 -0
- data/lib/stationery/builder.rb +12 -2
- data/lib/stationery/canvas/glyphs.rb +40 -0
- data/lib/stationery/canvas/interface.rb +169 -0
- data/lib/stationery/canvas/marking.rb +32 -3
- data/lib/stationery/canvas/path.rb +32 -70
- data/lib/stationery/canvas/surfacing.rb +67 -0
- data/lib/stationery/canvas/text.rb +4 -25
- data/lib/stationery/canvas.rb +24 -75
- data/lib/stationery/cli/examples.rb +66 -0
- data/lib/stationery/cli/inspect.rb +65 -0
- data/lib/stationery/cli/layout_text.rb +132 -0
- data/lib/stationery/cli/pictures.rb +50 -0
- data/lib/stationery/cli/render.rb +33 -6
- data/lib/stationery/cli/skill.rb +166 -0
- data/lib/stationery/cli.rb +5 -0
- data/lib/stationery/color.rb +27 -13
- data/lib/stationery/component.rb +1 -0
- data/lib/stationery/css/stylesheet.rb +9 -1
- data/lib/stationery/document.rb +135 -27
- data/lib/stationery/elements/forms.rb +2 -1
- data/lib/stationery/elements/lists.rb +6 -2
- data/lib/stationery/elements.rb +43 -7
- data/lib/stationery/examples.rb +22 -0
- data/lib/stationery/fonts/cff.rb +46 -0
- data/lib/stationery/fonts/charstring.rb +297 -0
- data/lib/stationery/fonts/fallback.rb +22 -1
- data/lib/stationery/fonts/font.rb +58 -3
- data/lib/stationery/fonts/font_book.rb +7 -3
- data/lib/stationery/fonts/glyf_outline.rb +230 -0
- data/lib/stationery/fonts/glyph_run.rb +37 -10
- data/lib/stationery/fonts/outline.rb +102 -0
- data/lib/stationery/fonts/shaped_run.rb +22 -4
- data/lib/stationery/fonts/true_type.rb +7 -0
- data/lib/stationery/forms/typeface.rb +2 -4
- data/lib/stationery/html/css.rb +31 -6
- data/lib/stationery/html/tree_builder/blocks.rb +7 -3
- data/lib/stationery/images/images.rb +10 -0
- data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
- data/lib/stationery/images/jpeg/colors.rb +133 -0
- data/lib/stationery/images/jpeg/decoder.rb +253 -0
- data/lib/stationery/images/jpeg/huffman.rb +59 -0
- data/lib/stationery/images/jpeg/idct.rb +150 -0
- data/lib/stationery/images/jpeg/progressive.rb +128 -0
- data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
- data/lib/stationery/images/jpeg/scan.rb +116 -0
- data/lib/stationery/images/jpeg/upsampler.rb +81 -0
- data/lib/stationery/images/jpeg.rb +59 -1
- data/lib/stationery/images/png.rb +10 -7
- data/lib/stationery/images/resampled.rb +10 -2
- data/lib/stationery/images/webp.rb +6 -5
- data/lib/stationery/layout/barcode.rb +75 -0
- data/lib/stationery/layout/box/wrapping.rb +56 -0
- data/lib/stationery/layout/box.rb +41 -15
- data/lib/stationery/layout/columns/leveller.rb +65 -0
- data/lib/stationery/layout/columns/pour.rb +7 -0
- data/lib/stationery/layout/columns.rb +32 -15
- data/lib/stationery/layout/field.rb +3 -2
- data/lib/stationery/layout/floated.rb +12 -1
- data/lib/stationery/layout/flow/floating.rb +23 -5
- data/lib/stationery/layout/flow/placement.rb +16 -12
- data/lib/stationery/layout/flow/splitter.rb +56 -10
- data/lib/stationery/layout/flow.rb +1 -0
- data/lib/stationery/layout/image.rb +2 -2
- data/lib/stationery/layout/list_item.rb +41 -11
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +16 -2
- data/lib/stationery/layout/opening.rb +61 -0
- data/lib/stationery/layout/paginator.rb +24 -5
- data/lib/stationery/layout/svg.rb +1 -1
- data/lib/stationery/layout/table/cell.rb +39 -10
- data/lib/stationery/layout/table/row_splitter.rb +6 -2
- data/lib/stationery/layout/table/selection.rb +9 -3
- data/lib/stationery/layout/table/stream.rb +123 -0
- data/lib/stationery/layout/table/widths.rb +26 -0
- data/lib/stationery/layout/table.rb +151 -35
- data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +22 -2
- data/lib/stationery/minitest.rb +11 -3
- data/lib/stationery/monochrome/bitmap.rb +78 -0
- data/lib/stationery/monochrome/canvas.rb +19 -0
- data/lib/stationery/monochrome/dither.rb +74 -0
- data/lib/stationery/monochrome/grid.rb +131 -0
- data/lib/stationery/monochrome/painting.rb +140 -0
- data/lib/stationery/monochrome/rules.rb +141 -0
- data/lib/stationery/monochrome.rb +87 -0
- data/lib/stationery/page/format.rb +84 -0
- data/lib/stationery/page.rb +11 -7
- data/lib/stationery/page_templates.rb +13 -10
- data/lib/stationery/path.rb +138 -0
- data/lib/stationery/pdf/assembler.rb +14 -3
- data/lib/stationery/pdf/canvases.rb +51 -0
- data/lib/stationery/pdf/conformance.rb +43 -13
- data/lib/stationery/pdf/print_hints.rb +137 -0
- data/lib/stationery/pdf/serializer.rb +41 -0
- data/lib/stationery/raster/adjust.rb +67 -0
- data/lib/stationery/raster/canvas.rb +108 -0
- data/lib/stationery/raster/canvases.rb +49 -0
- data/lib/stationery/raster/flattener.rb +75 -0
- data/lib/stationery/raster/glyphs.rb +81 -0
- data/lib/stationery/raster/painter.rb +184 -0
- data/lib/stationery/raster/picture.rb +69 -0
- data/lib/stationery/raster/png.rb +32 -0
- data/lib/stationery/raster/render.rb +134 -0
- data/lib/stationery/raster/scanner.rb +164 -0
- data/lib/stationery/raster/shading.rb +77 -0
- data/lib/stationery/raster/spans.rb +66 -0
- data/lib/stationery/raster/stroker.rb +208 -0
- data/lib/stationery/raster/surface.rb +70 -0
- data/lib/stationery/raster.rb +44 -0
- data/lib/stationery/rich/nodes.rb +4 -4
- data/lib/stationery/rich/renderer/boxes.rb +37 -16
- data/lib/stationery/rich/renderer.rb +3 -2
- data/lib/stationery/skill/SKILL.md.erb +244 -0
- data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
- data/lib/stationery/skill/recipes/form.md.erb +78 -0
- data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
- data/lib/stationery/skill/recipes/label.md.erb +116 -0
- data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
- data/lib/stationery/skill/recipes/report.md.erb +113 -0
- data/lib/stationery/skill/reference/conformance.md.erb +45 -0
- data/lib/stationery/skill/reference/elements.md.erb +19 -0
- data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
- data/lib/stationery/skill/reference/forms.md.erb +5 -0
- data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
- data/lib/stationery/skill/reference/pages.md.erb +15 -0
- data/lib/stationery/skill/reference/printing.md.erb +33 -0
- data/lib/stationery/skill/reference/testing.md.erb +33 -0
- data/lib/stationery/skill.rb +101 -0
- data/lib/stationery/structure.rb +7 -6
- data/lib/stationery/svg/gradient.rb +12 -0
- data/lib/stationery/svg/painter.rb +1 -1
- data/lib/stationery/tagging/element.rb +9 -2
- data/lib/stationery/tagging/tree.rb +98 -2
- data/lib/stationery/testing/field_page.rb +89 -0
- data/lib/stationery/testing/inspector.rb +80 -2
- data/lib/stationery/testing/layout.rb +166 -0
- data/lib/stationery/testing/marked_text.rb +124 -18
- data/lib/stationery/testing/matchers.rb +63 -14
- data/lib/stationery/testing/page_reader.rb +146 -0
- data/lib/stationery/text/exclusions.rb +11 -0
- data/lib/stationery/text/paragraph.rb +13 -8
- data/lib/stationery/text/wrapper/one_word.rb +33 -0
- data/lib/stationery/text/wrapper.rb +62 -27
- data/lib/stationery/units.rb +45 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +67 -7
- data/lib/stationery/zpl/native.rb +51 -0
- data/lib/stationery/zpl/render.rb +56 -0
- data/lib/stationery/zpl.rb +88 -0
- data/lib/stationery.rb +48 -0
- metadata +118 -1
data/examples/resume.rb
ADDED
|
@@ -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
|