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:
48 files changed
+5661
-317
No files matched your search
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+900
-93
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user