P3: the IIF in plain ASCII with every change listed, and the CPA package as of one day (defects 7, 21)

The CPA package's general-journal.iif now holds every posted entry through
as_of (closing entries left out, D-36), written as plain ASCII with CR LF,
DOCNUM the entry id, QuickBooks Desktop's field limits and subaccount types
honoured, and every character written differently listed in the export's
warnings and the README (grouped by substitution, TINs masked). The 1099
worksheet stops at as_of too. PDFs print CJK, Arabic, Hebrew, Thai and
Devanagari (Noto fallbacks, OFL), and so does the viewer. has_full_tin reads
Unicode separators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 09:35:32 -06:00
1 parent 1e8f9275b9
commit 2c29d48ab9
48 files changed
+5661 -317

No files matched your search

+7
View File
@@ -18,10 +18,17 @@ typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
# BSD-3-Clause: the IIF's transliteration to ASCII (iif.rs, rules.md B-34).
# Pinned exactly: a new table would change what a CPA's file says.
deunicode = { version = "=1.6.0", default-features = false }
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
# The viewer's PDF reader (ledger-gui's hayro): reads back the text a PDF
# draws, glyph by glyph, in the tests.
hayro-interpret = "0.8"
kurbo = "0.13"
rqrr = { version = "0.11.0", default-features = false }
@@ -0,0 +1,144 @@
Noto Sans Arabic UI, Noto Sans Hebrew, Noto Sans Thai and Noto Sans
Devanagari, Regular (the PDFs' and the viewer's fallbacks for Arabic,
Hebrew, Thai and Devanagari text, which Inter lacks), unmodified:
NotoSansArabicUI-Regular.ttf Version 2.011 190,944 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
SHA-256 f4fe1866a64cba1e8b8d08a67cd29070f919edf778ca5de983c1f2a690fc953b
NotoSansHebrew-Regular.ttf Version 3.001 16,836 bytes
Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew)
SHA-256 04272f5600d0ec816d31d0df73b23aa8d3501ea359ebe820da31c11ffcf00853
NotoSansThai-Regular.ttf Version 2.002 20,960 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
SHA-256 d4303fe9c63ebb72759ca8b6d2040c8ae81689f7d08d7b91c656154382b49313
NotoSansDevanagari-Regular.ttf Version 2.007 184,228 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
SHA-256 9c7d935139ea6a1e6ad9dbac4f6d27ece1e04bca8123c8888d00a0f9df4724cd
Noto is a trademark of Google LLC. (These fonts name no Reserved Font
Name.)
Each is the static, unhinted Regular weight as the Noto project
publishes it (https://github.com/notofonts/notofonts.github.io), at the
last commit that changed it, retrieved 2026-10-20:
fonts/NotoSansArabicUI/unhinted/ttf/NotoSansArabicUI-Regular.ttf
commit 1ed0aaad710107ac0d6663ece26a4bb82ce6cff8
fonts/NotoSansHebrew/unhinted/ttf/NotoSansHebrew-Regular.ttf
commit 40ae54ff25c9e0ad997f62fde48297dd9dfe51c8
fonts/NotoSansThai/unhinted/ttf/NotoSansThai-Regular.ttf
commit 6da9d59fd18f429cb8dbfa87e87669204f77ddab
fonts/NotoSansDevanagari/unhinted/ttf/NotoSansDevanagari-Regular.ttf
commit 92d2ea744479d3379927f1fb5f8b0945dd0349b7
https://raw.githubusercontent.com/notofonts/notofonts.github.io/<commit>/<path>
Arabic in its UI form, not Noto Sans Arabic itself (2.013): the same
design and project, but 2.013 draws a letter's dots as marks of their
own, which the PDFs' typesetter (Typst 0.15) writes as text runs of
their own, each carrying the letter again, so a PDF's text (copied,
searched, read aloud) would say "شش" for "ش" and "سلام" as "شلام"; the
UI form, whose letters keep their dots and whose height fits a Latin
line, reads back as written.
Unhinted, not hinted: the outlines are the same, the hinting tables
(about 180 KB across the four) serve only Windows' old rasterizer, and
neither the PDFs nor the viewer use them. Kept whole: Arabic and
Devanagari need their shaping tables to join and order their letters.
Emoji are not bundled: a colour emoji font is about 10 MB, so an emoji
in a name prints as an empty box in the PDFs (the viewer draws egui's
own small emoji set).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,125 @@
Noto Sans CJK SC Regular, version 2.004, subset:
NotoSansCJKsc-Regular-subset.otf (the PDFs' fallback for Chinese,
Japanese and Korean text, and the other characters below that Inter
lacks).
Copyright (c) 2014-2021 Adobe (http://www.adobe.com/).
Noto is a trademark of Google Inc.
(Noto Sans CJK names no Reserved Font Name.)
Made from Noto Sans CJK 2.004 (https://github.com/notofonts/noto-cjk),
NotoSansCJK-Regular.ttc as Arch Linux packages it (noto-fonts-cjk
20240730-1; SHA-256
b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a),
face 2 (Noto Sans CJK SC), with fontTools 4.65.0, 2026-10-20:
every code point at or above U+1100 that face maps and Inter Regular
(Inter-Regular.ttf here) doesn't: 44,128 code points, among them all
of Hangul, kana, bopomofo, CJK punctuation and fullwidth forms, and
every CJK ideograph the face draws (20,976 of the basic block, 6,582
of Extension A, the compatibility ideographs, and 2,310 from
Extensions B to G), written as U+XXXX-YYYY ranges to cjk.txt, then
pyftsubset NotoSansCJK-Regular.ttc --font-number=2 \
--unicodes-file=cjk.txt --no-hinting --layout-features='' \
--drop-tables+=BASE,VORG,vhea,vmtx,GPOS,GSUB \
--output-file=NotoSansCJKsc-Regular-subset.otf
SHA-256 of the result:
ebf572d736e30b54bf5db087b15f323c16c465052aec788d9cc1d8db41884929
The subset is a Modified Version under the license below, which it
keeps.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+2 -2
View File
@@ -46,7 +46,7 @@
//! SOURCE (7-9): docs/spec/rules.md B-25;
//! docs/spec/sources/irs-partnership-basis.md.
use crate::pdf::{INTER, PdfError};
use crate::pdf::PdfError;
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
use crate::xlsx::{Formats, XlsxError, footer, header, number};
use ledger_core::TaxBasisRule;
@@ -512,7 +512,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
+111
View File
@@ -0,0 +1,111 @@
//! The fonts the books print in, bundled so nothing depends on what's
//! installed: Inter for the text, and a Noto fallback for each script
//! Inter lacks, so a vendor or an account named in Chinese, Japanese,
//! Korean, Arabic, Hebrew, Thai or Hindi prints as written, never as
//! empty boxes. Each font's provenance and license is in `fonts/`
//! (`Inter-LICENSE.txt`, `NotoSansCJK-LICENSE.txt`, `NotoSans-LICENSE.txt`;
//! all SIL OFL 1.1).
//!
//! ```text
//! GOAL: every name prints as the books keep it, in the PDFs and in the
//! viewer, from one copy of each font in the binary.
//! SOURCE: DECISION (lead, P3 review round 2, 2026-10-20: CJK, Arabic,
//! Hebrew, Thai and Devanagari; no emoji, a colour emoji font
//! being about 10 MB).
//! INVARIANTS:
//! 1. The PDFs (crate::pdf, crate::asset_register, crate::labels) set
//! their text in Inter, then try FALLBACKS in order, as each template
//! names them (`font: ("Inter", "Noto Sans CJK SC", ...)`).
//! 2. The viewer (ledger-gui) registers FALLBACKS, these same bytes,
//! after its own fonts: each font is in the binary once.
//! ```
/// Inter, the PDFs' text face, in the three weights the templates use.
pub static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The fallbacks for the scripts Inter lacks, each its family name and
/// its bytes, in the order tried: Noto Sans CJK SC (a subset: every
/// character from U+1100 on that the face draws and Inter doesn't: Hangul,
/// kana, the CJK ideographs, fullwidth forms), Noto Sans Arabic UI (its
/// letters keep their dots, so a PDF's text reads back as written:
/// `fonts/NotoSans-LICENSE.txt`), Noto Sans Hebrew, Noto Sans Thai and
/// Noto Sans Devanagari (each whole, its static Regular weight). Emoji
/// have none.
pub static FALLBACKS: [(&str, &[u8]); 5] = [
(
"Noto Sans CJK SC",
include_bytes!("../fonts/NotoSansCJKsc-Regular-subset.otf"),
),
(
"Noto Sans Arabic UI",
include_bytes!("../fonts/NotoSansArabicUI-Regular.ttf"),
),
(
"Noto Sans Hebrew",
include_bytes!("../fonts/NotoSansHebrew-Regular.ttf"),
),
(
"Noto Sans Thai",
include_bytes!("../fonts/NotoSansThai-Regular.ttf"),
),
(
"Noto Sans Devanagari",
include_bytes!("../fonts/NotoSansDevanagari-Regular.ttf"),
),
];
/// Every font the PDFs use (INVARIANT 1), as Typst takes them: the
/// bundled bytes themselves, not a copy of them.
pub(crate) fn typst_fonts() -> Vec<typst::foundations::Bytes> {
INTER
.iter()
.copied()
.chain(FALLBACKS.iter().map(|(_, bytes)| *bytes))
.map(typst::foundations::Bytes::new)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// INVARIANT 1: every template names Inter, then every fallback, in
/// FALLBACKS' order; and each font's family is the one it is named
/// by.
#[test]
fn every_template_names_every_fallback_in_order() {
let mut want = String::from("font: (\"Inter\"");
for (family, _) in &FALLBACKS {
want.push_str(&format!(", \"{family}\""));
}
want.push(')');
for (name, t) in [
("statement.typ", include_str!("../templates/statement.typ")),
(
"asset_register.typ",
include_str!("../templates/asset_register.typ"),
),
("labels.typ", include_str!("../templates/labels.typ")),
] {
assert!(t.contains(&want), "{name} names {want}");
}
let book = typst::text::FontBook::from_fonts(
typst_fonts()
.into_iter()
.flat_map(|b| typst::text::Font::iter(b).collect::<Vec<_>>())
.collect::<Vec<_>>()
.iter(),
);
for (family, _) in &FALLBACKS {
assert!(
book.contains_family(&family.to_lowercase()),
"{family} is a family the fonts hold"
);
}
assert!(book.contains_family("inter"));
}
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -3,7 +3,7 @@
//! Each label carries the company name, the asset tag (its headline), what
//! the asset is, its serial number when recorded, and a QR code holding
//! exactly the tag. Labels are typeset by `templates/labels.typ` with the
//! same bundled Inter fonts as [`crate::pdf`]; the labels reach Typst as
//! same bundled fonts as [`crate::pdf`]; the labels reach Typst as
//! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes
//! from the label maker's own specification, vendored in
//! `docs/spec/sources/avery-5160.md`.
@@ -38,7 +38,7 @@
//! - A partly used sheet with 7 labels left: skip 23.
//! ```
use crate::pdf::{INTER, PdfError};
use crate::pdf::PdfError;
use qrcode::render::svg;
use qrcode::{EcLevel, QrCode};
use std::sync::OnceLock;
@@ -145,7 +145,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
+6 -1
View File
@@ -8,12 +8,14 @@
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
//! - [`labels`]: print asset label sheets with QR codes.
//! - [`fonts`]: the bundled fonts the PDFs and the viewer share.
//!
//! All money stays `Decimal`; no floats in money paths.
pub mod asset_register;
pub mod bank_csv;
pub mod chart_csv;
pub mod fonts;
pub mod iif;
pub mod labels;
pub mod pdf;
@@ -24,7 +26,10 @@ pub mod xlsx;
pub use asset_register::{asset_register_pdf, asset_register_xlsx};
pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif};
pub use iif::{
Iif, IifAccount, IifChange, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX,
QbAccountType, iif_accounts, journal_to_iif,
};
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
+121 -10
View File
@@ -4,10 +4,14 @@
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! what's installed, and it is deterministic: the same statement and
//! report metadata render to the same bytes.
//! and labels are data, never markup. The fonts are bundled, so output
//! doesn't depend on what's installed, and it is deterministic: the same
//! statement and report metadata render to the same bytes. Inter sets
//! the text; the Noto fallbacks ([`crate::fonts`]) draw what Inter can't:
//! Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, so a
//! vendor or an account named in them prints as written, right-to-left
//! where it is written so, never as empty boxes. (Emoji have no font
//! here: an emoji prints as an empty box.)
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
@@ -42,11 +46,6 @@ pub enum PdfError {
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
pub(crate) static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
@@ -54,7 +53,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
@@ -124,6 +123,10 @@ fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError>
/// ("#panic()", "*", "$", "[") prints as text and NEVER runs.
/// 3. Every row reaches the page: template_data carries each row's label,
/// kind, and amounts split by amount_parts, in order.
/// 4. Every character prints as itself when a bundled font has it:
/// Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari
/// from the Noto fallbacks (crate::fonts), Arabic and Hebrew right to
/// left, never an empty box.
/// ```
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset(st, meta)?;
@@ -221,4 +224,112 @@ mod tests {
assert!(doc.pages().len() > 1, "200 rows should span pages");
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
}
/// Each text run on the pages: its text, its font's family, and its
/// glyphs' ids in that font (0 is the font's empty box, "notdef").
fn runs(doc: &PagedDocument) -> Vec<(String, String, Vec<u16>)> {
use typst::layout::{Frame, FrameItem};
fn walk(frame: &Frame, out: &mut Vec<(String, String, Vec<u16>)>) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => walk(&g.frame, out),
FrameItem::Text(t) => out.push((
t.text.to_string(),
t.font.font().info().family.clone(),
t.glyphs.iter().map(|g| g.id).collect(),
)),
_ => {}
}
}
}
let mut out = Vec::new();
for p in doc.pages() {
walk(&p.frame, &mut out);
}
out
}
/// INVARIANT 4: a company, an account and a vendor named in Chinese,
/// Japanese and Korean print in the CJK fallback, every glyph a real
/// one; the rest stays Inter. (With Inter alone, every one of these
/// was glyph 0, an empty box.)
#[test]
fn chinese_japanese_and_korean_print_as_written() {
let mut m = meta();
m.company = "北京 Trading 株式会社".to_string();
let mut st = profit_and_loss(&pl());
st.rows.push(Row {
kind: RowKind::Line,
label: "6100 東京 서울 ありがとう ABC".into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
let runs = runs(&typeset(&st, &m).unwrap());
let mut seen = String::new();
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
let cjk = text.chars().any(|c| {
matches!(u32::from(c), 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xFF00..=0xFFEF)
});
let ascii = text.is_ascii();
if cjk {
assert_eq!(family, "Noto Sans CJK SC", "{text:?}");
seen.push_str(text);
} else if ascii {
assert_eq!(family, "Inter", "{text:?}");
}
}
for want in ["北京", "株式会社", "東京", "서울", "ありがとう", "ABC"] {
assert!(seen.contains(want), "{want} printed: {runs:?}");
}
}
/// INVARIANT 4 for the other bundled scripts: Arabic, Hebrew, Thai and
/// Devanagari each print in their own Noto fallback, every glyph a
/// real one (the right-to-left reading is checked on the PDF itself,
/// `tests/spec_import.rs` `pdf_prints_every_bundled_script_as_written`).
#[test]
fn arabic_hebrew_thai_and_devanagari_print_as_written() {
let mut m = meta();
m.company = "شركة Example".to_string();
let mut st = profit_and_loss(&pl());
for label in ["مكتب", "שלום", "สวัสดี", "नमस्ते"] {
st.rows.push(Row {
kind: RowKind::Line,
label: label.into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
}
let runs = runs(&typeset(&st, &m).unwrap());
let family_of = |c: char| match u32::from(c) {
0x0600..=0x06FF => Some("Noto Sans Arabic UI"),
0x0590..=0x05FF => Some("Noto Sans Hebrew"),
0x0E00..=0x0E7F => Some("Noto Sans Thai"),
0x0900..=0x097F => Some("Noto Sans Devanagari"),
_ => None,
};
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
if let Some(want) = text.chars().find_map(family_of) {
assert_eq!(family, want, "{text:?}");
}
}
// Every letter printed in its script's font.
for c in "شركةمكتبשלוםสวัสดีनमस्ते".chars() {
let want = family_of(c).unwrap();
assert!(
runs.iter().any(|(t, f, _)| f == want && t.contains(c)),
"{c} printed in {want}: {runs:?}"
);
}
}
}
@@ -26,7 +26,7 @@
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
flipped: true,
+1 -1
View File
@@ -20,7 +20,7 @@
#let quiet = luma(90)
#set document(title: d.company + " — Asset labels", author: d.company)
#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), fill: ink, number-width: "tabular", hyphenate: false)
#set par(leading: 0.28em, spacing: 0pt)
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
+1 -1
View File
@@ -28,7 +28,7 @@
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
#set page(
paper: "us-letter",
flipped: wide,
File diff suppressed because it is too large. Load diff