From b156f0a9ba47a73f147d8afd3585b292cdbc2188 Mon Sep 17 00:00:00 2001 From: Ekrem Seren <18965+havan@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:32:49 +0300 Subject: [PATCH 01/31] feat(core): read and subset TrueType fonts, with DejaVu moved into core --- NOTICE | 12 +- ROADMAP.md | 2 +- .../assets/fonts/DejaVuSans.ttf | Bin .../assets/fonts/DejaVuSansMono.ttf | Bin .../assets/fonts/LICENSE-DejaVu.txt | 0 crates/seacraft-core/src/fonts.rs | 13 + crates/seacraft-core/src/lib.rs | 4 + crates/seacraft-core/src/ttf.rs | 584 ++++++++++++++++++ crates/seacraft-gui/Cargo.toml | 2 +- crates/seacraft-gui/src/panels.rs | 2 +- crates/seacraft-gui/src/theme.rs | 14 +- xtask/src/main.rs | 2 +- 12 files changed, 616 insertions(+), 19 deletions(-) rename crates/{seacraft-gui => seacraft-core}/assets/fonts/DejaVuSans.ttf (100%) rename crates/{seacraft-gui => seacraft-core}/assets/fonts/DejaVuSansMono.ttf (100%) rename crates/{seacraft-gui => seacraft-core}/assets/fonts/LICENSE-DejaVu.txt (100%) create mode 100644 crates/seacraft-core/src/fonts.rs create mode 100644 crates/seacraft-core/src/ttf.rs diff --git a/NOTICE b/NOTICE index 4f886d2..45234ac 100644 --- a/NOTICE +++ b/NOTICE @@ -32,11 +32,13 @@ from solidcore's crates/solidcore/src/nurbs/srf_fit.rs, licensed under the Apach the Apache License, Version 2.0, except where otherwise noted. Fonts -The desktop GUI embeds DejaVu Sans and DejaVu Sans Mono 2.37, unmodified, from -crates/seacraft-gui/assets/fonts. They are not under the GPL. Bitstream Vera Fonts: Copyright (c) -2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. Arev -Fonts: Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. DejaVu changes are in the public -domain. The licence is in crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt. +The program embeds DejaVu Sans and DejaVu Sans Mono 2.37 from crates/seacraft-core/assets/fonts. The +desktop GUI uses them unmodified. PDF exports embed a subset of DejaVu Sans: the glyphs the document +uses, with the font's own name table and copyright notice. They are not under the GPL. Bitstream +Vera Fonts: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark +of Bitstream, Inc. Arev Fonts: Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. DejaVu +changes are in the public domain. The licence is in crates/seacraft-core/assets/fonts/LICENSE- +DejaVu.txt. Dependencies Dependency licences are in THIRD-PARTY-LICENSES.txt: a list in this repository, and the full texts diff --git a/ROADMAP.md b/ROADMAP.md index 3830b5d..0441289 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,7 +81,7 @@ takes about 140 ms to build on the window thread and 12 ms to upload. ### M5: Binaries - [x] Replace egui's default fonts, whose `Ubuntu-Light.ttf` is under the Ubuntu Font Licence, with DejaVu - (`crates/seacraft-gui/assets/fonts`, `NOTICE`). + (`crates/seacraft-core/assets/fonts`, `NOTICE`). - [x] Ship full licence texts with binaries: `cargo xtask dist` writes them from the crates that are compiled in. - [x] Add release packaging: a version tag builds a Linux archive, AppImage and Flatpak bundle, a Windows zip and macOS disk images, then creates a draft release (`.github/workflows/release.yml`, `packaging/`). diff --git a/crates/seacraft-gui/assets/fonts/DejaVuSans.ttf b/crates/seacraft-core/assets/fonts/DejaVuSans.ttf similarity index 100% rename from crates/seacraft-gui/assets/fonts/DejaVuSans.ttf rename to crates/seacraft-core/assets/fonts/DejaVuSans.ttf diff --git a/crates/seacraft-gui/assets/fonts/DejaVuSansMono.ttf b/crates/seacraft-core/assets/fonts/DejaVuSansMono.ttf similarity index 100% rename from crates/seacraft-gui/assets/fonts/DejaVuSansMono.ttf rename to crates/seacraft-core/assets/fonts/DejaVuSansMono.ttf diff --git a/crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt b/crates/seacraft-core/assets/fonts/LICENSE-DejaVu.txt similarity index 100% rename from crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt rename to crates/seacraft-core/assets/fonts/LICENSE-DejaVu.txt diff --git a/crates/seacraft-core/src/fonts.rs b/crates/seacraft-core/src/fonts.rs new file mode 100644 index 0000000..84d4940 --- /dev/null +++ b/crates/seacraft-core/src/fonts.rs @@ -0,0 +1,13 @@ +//! The fonts compiled into the program: DejaVu Sans and DejaVu Sans Mono 2.37, unmodified, from +//! `assets/fonts`, with their licence. The desktop GUI sets its text in them, and PDF exports embed +//! a subset of DejaVu Sans ([`crate::ttf`], [`crate::pdf`]). They are not under the GPL; `NOTICE` +//! carries their copyright lines. + +/// DejaVu Sans 2.37, the whole file. +pub const DEJAVU_SANS: &[u8] = include_bytes!("../assets/fonts/DejaVuSans.ttf"); + +/// DejaVu Sans Mono 2.37, the whole file. +pub const DEJAVU_SANS_MONO: &[u8] = include_bytes!("../assets/fonts/DejaVuSansMono.ttf"); + +/// The licence both fonts are distributed under. +pub const DEJAVU_LICENCE: &str = include_str!("../assets/fonts/LICENSE-DejaVu.txt"); diff --git a/crates/seacraft-core/src/lib.rs b/crates/seacraft-core/src/lib.rs index a3b78f7..2cd8b2a 100644 --- a/crates/seacraft-core/src/lib.rs +++ b/crates/seacraft-core/src/lib.rs @@ -37,6 +37,8 @@ pub mod dxf; pub mod examples; pub mod export; pub mod fef; +/// The embedded DejaVu fonts and their licence. +pub mod fonts; pub mod hydro; pub mod iges; /// Crash-safe file output: temporary sibling, sync, rename. @@ -67,6 +69,8 @@ pub mod threemf; pub mod topology; pub mod transform; pub mod trimopt; +/// Read a TrueType font and write a subset of it. +pub mod ttf; pub mod weather; pub use analysis::AnalysisMode; diff --git a/crates/seacraft-core/src/ttf.rs b/crates/seacraft-core/src/ttf.rs new file mode 100644 index 0000000..f633785 --- /dev/null +++ b/crates/seacraft-core/src/ttf.rs @@ -0,0 +1,584 @@ +//! Read a TrueType font, and write a subset of one. +//! +//! Enough of the format to set text in a PDF ([`crate::pdf`]): which glyph draws a character, how +//! wide it is, the font's vertical metrics, and a smaller copy of the file that holds only the +//! glyphs a document uses. The subset keeps every glyph id and empties the outlines it does not +//! need, so a glyph id read from the whole font is still right in the subset. +//! +//! Every read is checked: a damaged file is an `Err`, never a panic. +//! +//! Written from the OpenType specification's table descriptions (`head`, `hhea`, `maxp`, `hmtx`, +//! `loca`, `glyf`, `cmap`, `post`, `OS/2`); nothing here is ported. + +use std::collections::BTreeSet; + +/// A font's measurements, in font units. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Metrics { + /// Font units in one em, the unit every other number here is in. + pub units_per_em: u16, + /// Height above the baseline (`hhea`). + pub ascent: i16, + /// Depth below the baseline, negative (`hhea`). + pub descent: i16, + /// Height of a capital letter: `OS/2` `sCapHeight` when the font states it, else the ascent. + pub cap_height: i16, + /// The box that holds every glyph: x min, y min, x max, y max (`head`). + pub bbox: [i16; 4], +} + +/// A parsed TrueType font, borrowing the file's bytes. +#[derive(Debug, Clone)] +pub struct Font<'a> { + data: &'a [u8], + cmap: Cmap<'a>, + hmtx: &'a [u8], + glyphs: u16, + h_metrics: u16, + metrics: Metrics, +} + +/// The character map subtable in use. +#[derive(Debug, Clone, Copy)] +enum Cmap<'a> { + /// Format 4: segments of 16 bit characters. + Segments(&'a [u8]), + /// Format 12: groups of 32 bit characters. + Groups(&'a [u8]), +} + +fn u16_at(d: &[u8], at: usize) -> Option { + let b = d.get(at..at.checked_add(2)?)?; + Some(u16::from_be_bytes([b[0], b[1]])) +} + +fn i16_at(d: &[u8], at: usize) -> Option { + u16_at(d, at).map(|v| v as i16) +} + +fn u32_at(d: &[u8], at: usize) -> Option { + let b = d.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// The bytes of the table called `tag`, or `None` when the file has none or is cut short. +pub fn table<'a>(data: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> { + let count = u16_at(data, 4)? as usize; + for i in 0..count { + let rec = 12 + 16 * i; + if data.get(rec..rec + 4)? == tag { + let start = u32_at(data, rec + 8)? as usize; + let len = u32_at(data, rec + 12)? as usize; + return data.get(start..start.checked_add(len)?); + } + } + None +} + +/// The bytes of one glyph in `glyf`: empty for a glyph with no outline (a space, or a glyph a +/// subset left out), `None` when the font has no such glyph or is damaged. +pub fn outline(data: &[u8], glyph: u16) -> Option<&[u8]> { + let long = i16_at(table(data, b"head")?, 50)? != 0; + let glyphs = u16_at(table(data, b"maxp")?, 4)?; + if glyph >= glyphs { + return None; + } + let loca = table(data, b"loca")?; + let at = |i: usize| -> Option { + if long { + u32_at(loca, 4 * i).map(|v| v as usize) + } else { + u16_at(loca, 2 * i).map(|v| 2 * v as usize) + } + }; + let (start, end) = (at(glyph as usize)?, at(glyph as usize + 1)?); + table(data, b"glyf")?.get(start..end) +} + +/// The glyphs a composite glyph is built from; empty for a simple or an empty glyph. +fn components(outline: &[u8]) -> Option> { + const WORDS: u16 = 0x0001; + const SCALE: u16 = 0x0008; + const MORE: u16 = 0x0020; + const X_Y_SCALE: u16 = 0x0040; + const TWO_BY_TWO: u16 = 0x0080; + let mut out = Vec::new(); + if outline.is_empty() || i16_at(outline, 0)? >= 0 { + return Some(out); + } + let mut at = 10; + loop { + let flags = u16_at(outline, at)?; + out.push(u16_at(outline, at + 2)?); + at += 4 + if flags & WORDS != 0 { 4 } else { 2 }; + at += if flags & SCALE != 0 { + 2 + } else if flags & X_Y_SCALE != 0 { + 4 + } else if flags & TWO_BY_TWO != 0 { + 8 + } else { + 0 + }; + if flags & MORE == 0 { + return Some(out); + } + } +} + +/// The sum of a table's big-endian 32 bit words, the last one padded with zeros. +fn checksum(d: &[u8]) -> u32 { + d.chunks(4).fold(0u32, |sum, c| { + let mut w = [0u8; 4]; + w[..c.len()].copy_from_slice(c); + sum.wrapping_add(u32::from_be_bytes(w)) + }) +} + +impl<'a> Font<'a> { + /// Read a TrueType file. Fails with a reason when a table this reader needs is missing, cut + /// short or inconsistent. + pub fn parse(data: &'a [u8]) -> Result, String> { + let need = |tag: &[u8; 4], min: usize| -> Result<&'a [u8], String> { + let name = String::from_utf8_lossy(tag).into_owned(); + let t = table(data, tag).ok_or(format!("the font has no complete `{name}` table"))?; + if t.len() < min { + return Err(format!("the font's `{name}` table is too short")); + } + Ok(t) + }; + match u32_at(data, 0) { + Some(0x0001_0000) | Some(0x7472_7565) => {} + Some(_) => return Err("not a TrueType font (no TrueType outlines)".into()), + None => return Err("not a font: the file is too short".into()), + } + let head = need(b"head", 54)?; + let hhea = need(b"hhea", 36)?; + let maxp = need(b"maxp", 6)?; + let short = "the font is damaged"; + let units_per_em = u16_at(head, 18).ok_or(short)?; + if units_per_em == 0 { + return Err("the font's em size is zero".into()); + } + let glyphs = u16_at(maxp, 4).ok_or(short)?; + let h_metrics = u16_at(hhea, 34).ok_or(short)?; + if glyphs == 0 || h_metrics == 0 || h_metrics > glyphs { + return Err("the font's glyph counts are inconsistent".into()); + } + let hmtx = need(b"hmtx", 4 * h_metrics as usize)?; + let long = i16_at(head, 50).ok_or(short)? != 0; + let loca = need(b"loca", (glyphs as usize + 1) * if long { 4 } else { 2 })?; + let glyf = need(b"glyf", 0)?; + let last = if long { + u32_at(loca, 4 * glyphs as usize).map(|v| v as usize) + } else { + u16_at(loca, 2 * glyphs as usize).map(|v| 2 * v as usize) + }; + if last.ok_or(short)? > glyf.len() { + return Err("the font's glyph table is cut short".into()); + } + let ascent = i16_at(hhea, 4).ok_or(short)?; + let cap_height = table(data, b"OS/2") + .filter(|os2| u16_at(os2, 0).is_some_and(|version| version >= 2)) + .and_then(|os2| i16_at(os2, 88)) + .filter(|cap| *cap > 0) + .unwrap_or(ascent); + let mut bbox = [0i16; 4]; + for (i, b) in bbox.iter_mut().enumerate() { + *b = i16_at(head, 36 + 2 * i).ok_or(short)?; + } + Ok(Font { + data, + cmap: Self::character_map(need(b"cmap", 4)?)?, + hmtx, + glyphs, + h_metrics, + metrics: Metrics { + units_per_em, + ascent, + descent: i16_at(hhea, 6).ok_or(short)?, + cap_height, + bbox, + }, + }) + } + + /// The Unicode subtable to read: Windows full repertoire, then Windows 16 bit, then Unicode. + fn character_map(cmap: &'a [u8]) -> Result, String> { + let count = u16_at(cmap, 2).unwrap_or(0) as usize; + let mut best: Option<(u8, Cmap<'a>)> = None; + for i in 0..count { + let rec = 4 + 8 * i; + let (Some(platform), Some(encoding), Some(offset)) = ( + u16_at(cmap, rec), + u16_at(cmap, rec + 2), + u32_at(cmap, rec + 4), + ) else { + break; + }; + let Some(sub) = cmap.get(offset as usize..) else { + continue; + }; + let map = match u16_at(sub, 0) { + Some(4) => Cmap::Segments(sub), + Some(12) => Cmap::Groups(sub), + _ => continue, + }; + let rank = match (platform, encoding, map) { + (3, 10, Cmap::Groups(_)) => 0, + (3, 1, Cmap::Segments(_)) => 1, + (0, _, Cmap::Groups(_)) => 2, + (0, _, Cmap::Segments(_)) => 3, + _ => continue, + }; + if best.is_none_or(|(r, _)| rank < r) { + best = Some((rank, map)); + } + } + best.map(|(_, map)| map) + .ok_or("the font has no Unicode character map this reader understands".into()) + } + + /// The font's measurements. + pub fn metrics(&self) -> Metrics { + self.metrics + } + + /// How many glyphs the font has; glyph ids run from 0 to one less. + pub fn glyph_count(&self) -> u16 { + self.glyphs + } + + /// The glyph that draws `c`, or `None` when the font has none. Glyph 0, the font's "missing" + /// box, counts as none. + pub fn glyph(&self, c: char) -> Option { + let code = c as u32; + let id = match self.cmap { + Cmap::Groups(sub) => { + let groups = u32_at(sub, 12)? as usize; + let group = |i: usize| -> Option<(u32, u32, u32)> { + let at = 16 + 12 * i; + Some((u32_at(sub, at)?, u32_at(sub, at + 4)?, u32_at(sub, at + 8)?)) + }; + let (mut lo, mut hi) = (0, groups); + let mut found = None; + while lo < hi { + let mid = (lo + hi) / 2; + let (start, end, first) = group(mid)?; + if code < start { + hi = mid; + } else if code > end { + lo = mid + 1; + } else { + found = Some(first.checked_add(code - start)?); + break; + } + } + found? + } + Cmap::Segments(sub) => { + let code = u16::try_from(code).ok()?; + let seg_x2 = u16_at(sub, 6)? as usize; + let (ends, starts) = (14, 16 + seg_x2); + let (deltas, ranges) = (16 + 2 * seg_x2, 16 + 3 * seg_x2); + let mut found = None; + for i in (0..seg_x2).step_by(2) { + if u16_at(sub, ends + i)? < code { + continue; + } + let start = u16_at(sub, starts + i)?; + if start > code { + break; + } + let delta = u16_at(sub, deltas + i)?; + let range = u16_at(sub, ranges + i)? as usize; + found = Some(if range == 0 { + code.wrapping_add(delta) as u32 + } else { + let at = ranges + i + range + 2 * (code - start) as usize; + match u16_at(sub, at)? { + 0 => 0, + g => g.wrapping_add(delta) as u32, + } + }); + break; + } + found? + } + }; + u16::try_from(id) + .ok() + .filter(|g| *g != 0 && *g < self.glyphs) + } + + /// How far the pen moves after `glyph`, in font units; 0 for a glyph the font does not have. + pub fn advance(&self, glyph: u16) -> u16 { + if glyph >= self.glyphs { + return 0; + } + // Glyphs past the last full record share its advance. + let record = glyph.min(self.h_metrics - 1) as usize; + u16_at(self.hmtx, 4 * record).unwrap_or(0) + } + + /// A copy of the font that keeps only `glyphs` (and glyph 0, and the parts composite glyphs + /// are built from). Glyph ids do not change: every other glyph stays in the font with an empty + /// outline. The result has no `cmap`, so it is for embedding where the document addresses + /// glyphs by id, as a PDF does. The same input gives the same bytes. + pub fn subset(&self, glyphs: &BTreeSet) -> Result, String> { + let damaged = || "the font is damaged".to_string(); + let mut keep: BTreeSet = BTreeSet::new(); + let mut todo: Vec = std::iter::once(0).chain(glyphs.iter().copied()).collect(); + while let Some(g) = todo.pop() { + if g >= self.glyphs { + return Err(format!( + "glyph {g} is not in the font, which has {}", + self.glyphs + )); + } + if !keep.insert(g) { + continue; + } + let parts = components(outline(self.data, g).ok_or_else(damaged)?); + todo.extend( + parts + .ok_or_else(damaged)? + .into_iter() + .filter(|p| !keep.contains(p)), + ); + } + + let mut glyf = Vec::new(); + let mut loca = Vec::with_capacity(4 * (self.glyphs as usize + 1)); + for g in 0..self.glyphs { + loca.extend((glyf.len() as u32).to_be_bytes()); + if keep.contains(&g) { + glyf.extend(outline(self.data, g).ok_or_else(damaged)?); + // Long offsets are to four byte boundaries. + glyf.resize(glyf.len().next_multiple_of(4), 0); + } + } + loca.extend((glyf.len() as u32).to_be_bytes()); + + let copy = |tag: &[u8; 4]| table(self.data, tag).map(<[u8]>::to_vec); + let mut head = copy(b"head").ok_or_else(damaged)?; + head.get_mut(8..12).ok_or_else(damaged)?.fill(0); // checkSumAdjustment, set below + head.get_mut(50..52) + .ok_or_else(damaged)? + .copy_from_slice(&1i16.to_be_bytes()); + // A version 3 `post` names no glyphs: the 32 byte header is the whole table. + let mut post = copy(b"post").unwrap_or_default(); + post.resize(32, 0); + post[..4].copy_from_slice(&0x0003_0000u32.to_be_bytes()); + + let mut tables: Vec<([u8; 4], Vec)> = vec![ + (*b"glyf", glyf), + (*b"head", head), + (*b"hhea", copy(b"hhea").ok_or_else(damaged)?), + (*b"hmtx", copy(b"hmtx").ok_or_else(damaged)?), + (*b"loca", loca), + (*b"maxp", copy(b"maxp").ok_or_else(damaged)?), + (*b"post", post), + ]; + // The hinting programs, and `name`, which carries the copyright notice. + for tag in [b"cvt ", b"fpgm", b"prep", b"name"] { + if let Some(t) = copy(tag) { + tables.push((*tag, t)); + } + } + tables.sort_by_key(|(tag, _)| *tag); + + let count = tables.len() as u16; + let entry_selector = count.ilog2() as u16; + let search_range = 16 * (1u16 << entry_selector); + let mut out = Vec::new(); + out.extend(0x0001_0000u32.to_be_bytes()); + for v in [ + count, + search_range, + entry_selector, + 16 * count - search_range, + ] { + out.extend(v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + let mut head_at = 0; + for (tag, body) in &tables { + if tag == b"head" { + head_at = offset; + } + out.extend(tag); + out.extend(checksum(body).to_be_bytes()); + out.extend((offset as u32).to_be_bytes()); + out.extend((body.len() as u32).to_be_bytes()); + offset += body.len().next_multiple_of(4); + } + for (_, body) in &tables { + out.extend(body); + out.resize(out.len().next_multiple_of(4), 0); + } + let adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out)); + out[head_at + 8..head_at + 12].copy_from_slice(&adjustment.to_be_bytes()); + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const SANS: &[u8] = crate::fonts::DEJAVU_SANS; + + fn font() -> Font<'static> { + Font::parse(SANS).expect("DejaVu Sans parses") + } + + fn be16(d: &[u8], at: usize) -> u16 { + u16::from_be_bytes([d[at], d[at + 1]]) + } + + fn be32(d: &[u8], at: usize) -> u32 { + u32::from_be_bytes([d[at], d[at + 1], d[at + 2], d[at + 3]]) + } + + /// The sum of a table's big-endian words, the last one padded with zeros. + fn word_sum(d: &[u8]) -> u32 { + d.chunks(4).fold(0u32, |sum, c| { + let mut w = [0u8; 4]; + w[..c.len()].copy_from_slice(c); + sum.wrapping_add(u32::from_be_bytes(w)) + }) + } + + #[test] + fn dejavu_has_the_turkish_letters() { + for c in "ğĞşŞıİöÖüÜçÇ".chars() { + assert!(font().glyph(c).is_some(), "{c}"); + } + } + + #[test] + fn digits_share_one_advance() { + let f = font(); + let w = f.advance(f.glyph('0').unwrap()); + assert!(w > 0); + for c in '1'..='9' { + assert_eq!(f.advance(f.glyph(c).unwrap()), w, "{c}"); + } + } + + #[test] + fn a_character_the_font_lacks_has_no_glyph() { + // U+8239, the Chinese character for a boat. Written as an escape because a GUI test + // checks that the embedded fonts draw every character in the sources. + assert_eq!(font().glyph('\u{8239}'), None); + } + + /// 2048 is the em size stored in this file's `head` table, read here a second way. + #[test] + fn the_em_size_is_read_from_head() { + let head = table(SANS, b"head").unwrap(); + assert_eq!(font().metrics().units_per_em, be16(head, 18)); + assert_eq!(font().metrics().units_per_em, 2048); + } + + /// The size limit is this font's fixed tables (`hmtx` 25 kB, `loca` 25 kB, `name` 16 kB, the + /// hinting programs 2 kB) plus a handful of outlines; the whole file is 757 kB. + #[test] + fn a_subset_keeps_used_outlines_and_empties_the_rest() { + let f = font(); + let used: BTreeSet = "Kayık 9 m".chars().map(|c| f.glyph(c).unwrap()).collect(); + let sub = f.subset(&used).unwrap(); + for g in &used { + assert_eq!(outline(&sub, *g), outline(SANS, *g), "glyph {g}"); + } + let z = f.glyph('Z').unwrap(); + assert!(!outline(SANS, z).unwrap().is_empty()); + assert!(outline(&sub, z).unwrap().is_empty()); + assert!(sub.len() < 80_000, "{}", sub.len()); + assert_eq!(table(&sub, b"name"), table(SANS, b"name")); + assert_eq!(table(&sub, b"hmtx"), table(SANS, b"hmtx")); + assert_eq!(f.subset(&used).unwrap(), sub, "deterministic"); + // The subset is itself a font this reader accepts, apart from the `cmap` it leaves out. + assert!(table(&sub, b"cmap").is_none()); + assert_eq!(be16(table(&sub, b"maxp").unwrap(), 4), f.glyph_count()); + } + + #[test] + fn a_composite_glyph_brings_its_components() { + let f = font(); + let composite = "ğşçöüİ" + .chars() + .map(|c| f.glyph(c).unwrap()) + .find(|g| (be16(outline(SANS, *g).unwrap(), 0) as i16) < 0) + .expect("DejaVu builds one of these letters from parts"); + let sub = f.subset(&BTreeSet::from([composite])).unwrap(); + // The first component's glyph id follows the 10 byte header and the 2 byte flags. + let first = be16(outline(SANS, composite).unwrap(), 12); + assert_ne!(first, composite); + assert!(!outline(&sub, first).unwrap().is_empty()); + let parts = components(outline(SANS, composite).unwrap()).unwrap(); + assert!(parts.len() >= 2, "{parts:?}"); + assert_eq!(parts[0], first); + for part in parts { + assert_eq!(outline(&sub, part), outline(SANS, part), "component {part}"); + assert!(!outline(&sub, part).unwrap().is_empty()); + } + } + + /// The TrueType specification: a table's checksum is the sum of its words, `head` being summed + /// with `checkSumAdjustment` zero, and the whole file sums to `0xB1B0AFBA`. + #[test] + fn a_subset_has_valid_checksums() { + let f = font(); + let sub = f.subset(&BTreeSet::from([f.glyph('a').unwrap()])).unwrap(); + let tables = be16(&sub, 4) as usize; + assert!(tables >= 9, "{tables}"); + let mut last_tag = [0u8; 4]; + for i in 0..tables { + let rec = 12 + 16 * i; + let tag: [u8; 4] = sub[rec..rec + 4].try_into().unwrap(); + assert!(tag > last_tag, "the directory is sorted by tag"); + last_tag = tag; + let (sum, off, len) = ( + be32(&sub, rec + 4), + be32(&sub, rec + 8), + be32(&sub, rec + 12), + ); + assert_eq!(off % 4, 0); + let mut body = sub[off as usize..(off + len) as usize].to_vec(); + if &tag == b"head" { + body[8..12].fill(0); + } + assert_eq!(word_sum(&body), sum, "{}", String::from_utf8_lossy(&tag)); + } + assert_eq!(sub.len() % 4, 0); + assert_eq!(word_sum(&sub), 0xB1B0_AFBA); + assert_eq!(be32(table(&sub, b"post").unwrap(), 0), 0x0003_0000); + assert_eq!(be16(table(&sub, b"head").unwrap(), 50), 1, "long loca"); + } + + #[test] + fn a_broken_font_is_an_error_not_a_panic() { + assert!(Font::parse(b"").is_err()); + assert!(Font::parse(&SANS[..1000]).is_err()); + assert!(Font::parse(&[0xFF; 4096]).is_err()); + // Every prefix of the table directory, and a file whose tables are cut short. + for len in (0..400).chain([5_000, 50_000, 500_000]) { + assert!(Font::parse(&SANS[..len]).is_err(), "{len}"); + } + assert_eq!(outline(&SANS[..1000], 3), None); + assert_eq!(table(b"", b"head"), None); + } + + /// A glyph id past the end of the font has no advance and no outline, and cannot be subset. + #[test] + fn a_glyph_id_out_of_range_is_refused() { + let f = font(); + let n = f.glyph_count(); + assert_eq!(f.advance(n), 0); + assert_eq!(outline(SANS, n), None); + assert!(f.subset(&BTreeSet::from([n])).is_err()); + } +} diff --git a/crates/seacraft-gui/Cargo.toml b/crates/seacraft-gui/Cargo.toml index 5ab5e9e..04a26af 100644 --- a/crates/seacraft-gui/Cargo.toml +++ b/crates/seacraft-gui/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] seacraft-core = { path = "../seacraft-core" } # Every eframe default except `default_fonts`: egui's default fonts embed Ubuntu-Light, and SeaCraft -# embeds its own (`assets/fonts`). `system_fonts` stays, for text such as file names in other scripts. +# embeds its own (`seacraft_core::fonts`). `system_fonts` stays, for text such as file names in other scripts. eframe = { version = "0.37", default-features = false, features = [ "accesskit", "clipboard", diff --git a/crates/seacraft-gui/src/panels.rs b/crates/seacraft-gui/src/panels.rs index 6f96fab..c3f9ea5 100644 --- a/crates/seacraft-gui/src/panels.rs +++ b/crates/seacraft-gui/src/panels.rs @@ -738,7 +738,7 @@ pub fn about(ui: &mut egui::Ui) { ), ( "Font licence (DejaVu)", - include_str!("../assets/fonts/LICENSE-DejaVu.txt"), + seacraft_core::fonts::DEJAVU_LICENCE, ), ] { egui::CollapsingHeader::new(title).show(ui, |ui| { diff --git a/crates/seacraft-gui/src/theme.rs b/crates/seacraft-gui/src/theme.rs index 19f8cca..53cd4b5 100644 --- a/crates/seacraft-gui/src/theme.rs +++ b/crates/seacraft-gui/src/theme.rs @@ -194,21 +194,15 @@ pub fn caption_font() -> FontId { FontId::monospace(CAPTION_SIZE) } -/// The fonts compiled into the program, unmodified from DejaVu 2.37 (`assets/fonts`, with their -/// licence). Each family falls back on the other, so a symbol only one of them has still draws. +/// The fonts compiled into the program, unmodified from DejaVu 2.37 (`seacraft_core::fonts`, with +/// their licence). Each family falls back on the other, so a symbol only one of them has still draws. pub fn fonts() -> egui::FontDefinitions { const SANS: &str = "DejaVu Sans"; const MONO: &str = "DejaVu Sans Mono"; let mut fonts = egui::FontDefinitions::empty(); for (name, bytes) in [ - ( - SANS, - include_bytes!("../assets/fonts/DejaVuSans.ttf").as_slice(), - ), - ( - MONO, - include_bytes!("../assets/fonts/DejaVuSansMono.ttf").as_slice(), - ), + (SANS, seacraft_core::fonts::DEJAVU_SANS), + (MONO, seacraft_core::fonts::DEJAVU_SANS_MONO), ] { fonts.font_data.insert( name.to_owned(), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6752fef..bbd32be 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -776,7 +776,7 @@ fn step_dist(root: &Path, target: &str) { copy(root.join("packaging/README.txt"), stage.join("README.txt")); copy_folder("LICENSES", &stage.join("LICENSES")); copy( - root.join("crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt"), + root.join("crates/seacraft-core/assets/fonts/LICENSE-DejaVu.txt"), stage.join("LICENSES/DejaVu.txt"), ); copy_folder("examples", &stage.join("examples")); From b38b33fd6b003f78969e60c5e208edc8c0aad3a3 Mon Sep 17 00:00:00 2001 From: Ekrem Seren <18965+havan@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:41:17 +0300 Subject: [PATCH 02/31] feat(core): a dependency-free PDF writer with embedded Unicode text --- crates/seacraft-core/src/lib.rs | 2 + crates/seacraft-core/src/pdf.rs | 1010 ++++++++++++++++++++++ crates/seacraft-core/tests/pdf_render.rs | 129 +++ 3 files changed, 1141 insertions(+) create mode 100644 crates/seacraft-core/src/pdf.rs create mode 100644 crates/seacraft-core/tests/pdf_render.rs diff --git a/crates/seacraft-core/src/lib.rs b/crates/seacraft-core/src/lib.rs index 2cd8b2a..80d2723 100644 --- a/crates/seacraft-core/src/lib.rs +++ b/crates/seacraft-core/src/lib.rs @@ -53,6 +53,8 @@ pub mod offsets; pub mod optimize; mod par; pub mod parametric; +/// A small PDF writer: pages, reusable drawings, stroked lines, Unicode text. +pub mod pdf; pub mod planing; pub mod project; pub mod raster; diff --git a/crates/seacraft-core/src/pdf.rs b/crates/seacraft-core/src/pdf.rs new file mode 100644 index 0000000..4fedf50 --- /dev/null +++ b/crates/seacraft-core/src/pdf.rs @@ -0,0 +1,1010 @@ +//! A small PDF writer: pages at true size, drawings reused across pages, stroked lines, and +//! Unicode text in an embedded subset of DejaVu Sans. +//! +//! It exists so that patterns and tables can be printed without a dependency: PDF 1.4, streams +//! not compressed, a classic cross-reference table. The file carries no date and no random id, so +//! the same input gives the same bytes. +//! +//! Lengths here are PDF points, 1/72 inch; [`PT_PER_MM`] converts. Coordinates are written to +//! three decimals, about a third of a micrometre. +//! +//! Text is a Type 0 font over a CIDFontType2 with the `Identity-H` encoding, so a string is the +//! font's own glyph ids, and a `ToUnicode` map lets a reader search and copy it. A character +//! DejaVu Sans has no glyph for is drawn as `?` and reported in [`Document::missing`]; it never +//! fails the document. +//! +//! Written from the PDF 1.4 reference (file structure, content stream operators, composite +//! fonts); nothing here is ported. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::sync::OnceLock; + +use crate::ttf::Font; + +/// Points in one millimetre: a point is 1/72 inch and an inch is 25.4 mm, both by definition. +pub const PT_PER_MM: f64 = 72.0 / 25.4; + +/// The longest side a page may have, 200 inches (5.08 m): the limit PDF readers implement. +pub const MAX_PAGE_PT: f64 = 14_400.0; + +/// The name the embedded subset goes by. The six letter tag marks a subset. +const FONT_NAME: &str = "AAAAAA+DejaVuSans"; + +/// Which point of a line of text sits at the position given. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Anchor { + /// The start of the text: it runs on from the position. + Start, + /// The middle of the text. + Middle, + /// The end of the text: it runs up to the position. + End, +} + +/// How a line is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Stroke { + /// Width on paper, in millimetres. + pub width_mm: f64, + /// 0 is black, 1 is white. + pub grey: f64, + /// Dashes: millimetres on, millimetres off. `None` is a solid line. + pub dash_mm: Option<(f64, f64)>, +} + +/// A drawing added with [`Pdf::add_form`], to place on pages with [`Content::form`]. +#[derive(Clone, Copy, Debug)] +pub struct FormId(usize); + +/// The embedded font, parsed once. It is compiled in and its tests read it, so it parses. +fn font() -> &'static Font<'static> { + static FONT: OnceLock> = OnceLock::new(); + FONT.get_or_init(|| Font::parse(crate::fonts::DEJAVU_SANS).expect("the embedded font parses")) +} + +/// The glyph that draws `c`, or `None` when the font has none. +fn glyph_for(c: char) -> Option { + font().glyph(c) +} + +/// The glyph drawn in place of a character the font lacks. +fn question_mark() -> u16 { + glyph_for('?').unwrap_or(0) +} + +/// A number as PDF writes it here: three decimals, and never a negative zero. +fn num(v: f64) -> String { + let s = format!("{v:.3}"); + if s.trim_start_matches('-') + .chars() + .all(|c| c == '0' || c == '.') + { + s.trim_start_matches('-').to_owned() + } else { + s + } +} + +/// A rotation's cosine or sine: an exact `0`, `1` or `-1` where it is one, else six decimals. +fn turn(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r == 0.0 { + "0".to_owned() + } else if r == 1.0 { + "1".to_owned() + } else if r == -1.0 { + "-1".to_owned() + } else { + format!("{r:.6}") + } +} + +/// The width of one line of `text` at `size_pt`, in points. Characters the font lacks count as +/// the `?` that is drawn for them. +pub fn text_width_pt(text: &str, size_pt: f64) -> f64 { + let f = font(); + let units: u64 = text + .chars() + .map(|c| f.advance(glyph_for(c).unwrap_or_else(question_mark)) as u64) + .sum(); + units as f64 / f.metrics().units_per_em as f64 * size_pt +} + +/// What goes on a page or in a reusable drawing: lines, text and placed drawings, in the order +/// they are added. Coordinates are points, x to the right and y up, from the lower-left corner. +#[derive(Default, Clone, Debug)] +pub struct Content { + ops: String, + /// Glyphs shown, each with the character it stands for. + glyphs: BTreeMap, + missing: BTreeSet, + forms: BTreeSet, +} + +impl Content { + /// Empty content. + pub fn new() -> Self { + Self::default() + } + + /// Remember the graphics state (the clip and the transform) until [`Content::restore`]. + pub fn save(&mut self) { + self.ops.push_str("q\n"); + } + + /// Return to the state at the matching [`Content::save`]. + pub fn restore(&mut self) { + self.ops.push_str("Q\n"); + } + + /// Move everything drawn from here on by `(dx_pt, dy_pt)`. + pub fn translate(&mut self, dx_pt: f64, dy_pt: f64) { + let _ = writeln!(self.ops, "1 0 0 1 {} {} cm", num(dx_pt), num(dy_pt)); + } + + /// Draw only inside this rectangle from here on. + pub fn clip_rect(&mut self, x_pt: f64, y_pt: f64, w_pt: f64, h_pt: f64) { + let _ = writeln!( + self.ops, + "{} {} {} {} re W n", + num(x_pt), + num(y_pt), + num(w_pt), + num(h_pt) + ); + } + + /// A stroked line through `points_pt`, joined back to its start when `closed`. Fewer than two + /// points, or a point that is not a finite number, draws nothing. + pub fn polyline(&mut self, points_pt: &[(f64, f64)], closed: bool, stroke: Stroke) { + if points_pt.len() < 2 + || points_pt + .iter() + .any(|p| !p.0.is_finite() || !p.1.is_finite()) + { + return; + } + let dash = match stroke.dash_mm { + Some((on, off)) if on > 0.0 && off > 0.0 => { + format!("[{} {}]", num(on * PT_PER_MM), num(off * PT_PER_MM)) + } + _ => "[]".to_owned(), + }; + // Round joins, so a line of many short segments has no spikes; butt ends, so a line is + // exactly as long as its end points say. + let _ = writeln!( + self.ops, + "{} w {} G {dash} 0 d 1 j 0 J", + num(stroke.width_mm.max(0.0) * PT_PER_MM), + num(stroke.grey.clamp(0.0, 1.0)), + ); + for (i, (x, y)) in points_pt.iter().enumerate() { + let op = if i == 0 { 'm' } else { 'l' }; + let _ = writeln!(self.ops, "{} {} {op}", num(*x), num(*y)); + } + if closed { + self.ops.push_str("h\n"); + } + self.ops.push_str("S\n"); + } + + /// One line of text. `grey` 0 is black. `angle_deg` turns it anticlockwise about its anchor. + /// Characters without a glyph are drawn as `?` and recorded. + pub fn text( + &mut self, + at_pt: (f64, f64), + size_pt: f64, + angle_deg: f64, + anchor: Anchor, + grey: f64, + text: &str, + ) { + let finite = [at_pt.0, at_pt.1, size_pt, angle_deg] + .iter() + .all(|v| v.is_finite()); + if text.is_empty() || !finite || size_pt <= 0.0 { + return; + } + let mut hex = String::with_capacity(4 * text.len()); + for c in text.chars() { + let glyph = match glyph_for(c) { + Some(g) => { + self.glyphs.entry(g).or_insert(c); + g + } + None => { + self.missing.insert(c); + self.glyphs.entry(question_mark()).or_insert('?'); + question_mark() + } + }; + let _ = write!(hex, "{glyph:04X}"); + } + let back = match anchor { + Anchor::Start => 0.0, + Anchor::Middle => text_width_pt(text, size_pt) / 2.0, + Anchor::End => text_width_pt(text, size_pt), + }; + let (sin, cos) = angle_deg.to_radians().sin_cos(); + let (x, y) = (at_pt.0 - back * cos, at_pt.1 - back * sin); + let _ = writeln!( + self.ops, + "BT /F1 {} Tf {} g {} {} {} {} {} {} Tm <{hex}> Tj ET", + num(size_pt), + num(grey.clamp(0.0, 1.0)), + turn(cos), + turn(sin), + turn(-sin), + turn(cos), + num(x), + num(y), + ); + } + + /// Place a drawing added with [`Pdf::add_form`], under the current transform and clip. + pub fn form(&mut self, id: FormId) { + self.forms.insert(id.0); + let _ = writeln!(self.ops, "/X{} Do", id.0); + } + + /// The `/Resources` dictionary this content needs, given where the font and forms are. + fn resources(&self, font_object: usize, form_object: impl Fn(usize) -> usize) -> String { + let mut out = String::from("<<"); + if !self.glyphs.is_empty() { + let _ = write!(out, " /Font << /F1 {font_object} 0 R >>"); + } + if !self.forms.is_empty() { + out.push_str(" /XObject <<"); + for form in &self.forms { + let _ = write!(out, " /X{form} {} 0 R", form_object(*form)); + } + out.push_str(" >>"); + } + out.push_str(" >>"); + out + } +} + +/// A PDF document being put together. +#[derive(Debug, Clone)] +pub struct Pdf { + title: String, + forms: Vec<([f64; 4], Content)>, + pages: Vec<(f64, f64, Content)>, +} + +/// A finished PDF. +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + /// The file. + pub bytes: Vec, + /// How many pages it has. + pub pages: usize, + /// Characters the font has no glyph for, each drawn as `?`: sorted, no repeats. + pub missing: Vec, +} + +/// The file being written: object bodies appended in number order, with where each began. +struct Objects { + bytes: Vec, + offsets: Vec, +} + +impl Objects { + /// Append the next object; its number is the count so far plus one. + fn add(&mut self, body: &[u8]) { + self.offsets.push(self.bytes.len()); + let header = format!("{} 0 obj\n", self.offsets.len()); + self.bytes.extend(header.as_bytes()); + self.bytes.extend(body); + self.bytes.extend(b"\nendobj\n"); + } + + /// Append a stream object with these extra dictionary entries. + fn add_stream(&mut self, entries: &str, data: &[u8]) { + let mut body = format!("<< /Length {}{entries} >>\nstream\n", data.len()).into_bytes(); + body.extend(data); + body.extend(b"\nendstream"); + self.add(&body); + } +} + +impl Pdf { + /// An empty document. The title is what a reader shows in its window; any text will do. + pub fn new(title: &str) -> Self { + Self { + title: title.to_owned(), + forms: Vec::new(), + pages: Vec::new(), + } + } + + /// Add a drawing that pages (and later drawings) can place any number of times; the file + /// holds it once. `bbox_pt` is `[x min, y min, x max, y max]` in the drawing's coordinates: + /// nothing outside it is drawn. + pub fn add_form(&mut self, bbox_pt: [f64; 4], content: Content) -> FormId { + self.forms.push((bbox_pt, content)); + FormId(self.forms.len() - 1) + } + + /// Add a page of this size, in points. + pub fn add_page(&mut self, width_pt: f64, height_pt: f64, content: Content) { + self.pages.push((width_pt, height_pt, content)); + } + + /// How many pages have been added. + pub fn page_count(&self) -> usize { + self.pages.len() + } + + /// Write the file. Fails when the document has no page, when a page has no size or is larger + /// than a PDF page can be, or when a content names a drawing this document does not have. + pub fn finish(self) -> Result { + if self.pages.is_empty() { + return Err("a PDF needs at least one page".into()); + } + for (i, (w, h, _)) in self.pages.iter().enumerate() { + let page = i + 1; + if !(w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) { + return Err(format!("page {page} has no size ({w} x {h} pt)")); + } + if *w > MAX_PAGE_PT || *h > MAX_PAGE_PT { + return Err(format!( + "page {page} would be {:.2} x {:.2} m, and a PDF page can be at most 5.08 m \ + (200 in) on a side", + w / PT_PER_MM / 1000.0, + h / PT_PER_MM / 1000.0 + )); + } + } + let contents = || { + self.forms + .iter() + .map(|f| &f.1) + .chain(self.pages.iter().map(|p| &p.2)) + }; + for c in contents() { + if let Some(form) = c.forms.iter().find(|f| **f >= self.forms.len()) { + return Err(format!( + "a page places drawing {form}, which is not in this document" + )); + } + } + if self + .forms + .iter() + .any(|(bbox, _)| bbox.iter().any(|v| !v.is_finite())) + { + return Err("a drawing's bounds are not finite numbers".into()); + } + let mut glyphs: BTreeMap = BTreeMap::new(); + let mut missing: BTreeSet = BTreeSet::new(); + for c in contents() { + for (g, ch) in &c.glyphs { + glyphs.entry(*g).or_insert(*ch); + } + missing.extend(&c.missing); + } + + // Object numbers: 1 catalog, 2 page tree, 3 information, then five for the font when + // there is text, then the forms, then two for each page. + const FONT: usize = 4; + let font_objects = if glyphs.is_empty() { 0 } else { 5 }; + let first_form = 4 + font_objects; + let first_page = first_form + self.forms.len(); + let form_object = |form: usize| first_form + form; + + let mut file = Objects { + // The second line is a comment of bytes over 127, which tells a program that moves + // the file that it is not text. + bytes: b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec(), + offsets: Vec::new(), + }; + file.add(b"<< /Type /Catalog /Pages 2 0 R >>"); + let mut kids = String::new(); + for i in 0..self.pages.len() { + let _ = write!(kids, "{} 0 R ", first_page + 2 * i); + } + let tree = format!( + "<< /Type /Pages /Kids [{}] /Count {} >>", + kids.trim_end(), + self.pages.len() + ); + file.add(tree.as_bytes()); + let mut title = String::from("FEFF"); + for unit in self.title.encode_utf16() { + let _ = write!(title, "{unit:04X}"); + } + let info = format!( + "<< /Title <{title}> /Producer (SeaCraft {}) >>", + crate::VERSION + ); + file.add(info.as_bytes()); + + if !glyphs.is_empty() { + Self::write_font(&mut file, &glyphs)?; + } + for (bbox, content) in &self.forms { + let entries = format!( + " /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Resources {}", + num(bbox[0]), + num(bbox[1]), + num(bbox[2]), + num(bbox[3]), + content.resources(FONT, form_object) + ); + file.add_stream(&entries, content.ops.as_bytes()); + } + for (i, (w, h, content)) in self.pages.iter().enumerate() { + let page = format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources {} /Contents {} 0 R >>", + num(*w), + num(*h), + content.resources(FONT, form_object), + first_page + 2 * i + 1 + ); + file.add(page.as_bytes()); + file.add_stream("", content.ops.as_bytes()); + } + + let Objects { mut bytes, offsets } = file; + let xref = bytes.len(); + let size = offsets.len() + 1; + bytes.extend(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in &offsets { + bytes.extend(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend( + format!( + "trailer\n<< /Size {size} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + Ok(Document { + bytes, + pages: self.pages.len(), + missing: missing.into_iter().collect(), + }) + } + + /// The five font objects, numbers 4 to 8: the Type 0 font, its CID font, the descriptor, the + /// subset file and the map back to Unicode. + fn write_font(file: &mut Objects, glyphs: &BTreeMap) -> Result<(), String> { + let f = font(); + let m = f.metrics(); + // Font units to the thousandths of an em PDF measures glyphs in. + let milli = |units: f64| num(units * 1000.0 / m.units_per_em as f64); + file.add( + format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /{FONT_NAME} /Encoding /Identity-H \ + /DescendantFonts [5 0 R] /ToUnicode 8 0 R >>" + ) + .as_bytes(), + ); + let mut widths = String::new(); + for glyph in glyphs.keys() { + let _ = write!(widths, "{glyph} [{}] ", milli(f.advance(*glyph) as f64)); + } + file.add( + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{FONT_NAME} /CIDSystemInfo \ + << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 6 0 R \ + /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + widths.trim_end() + ) + .as_bytes(), + ); + file.add( + format!( + "<< /Type /FontDescriptor /FontName /{FONT_NAME} /Flags 4 /FontBBox [{} {} {} {}] \ + /ItalicAngle 0 /Ascent {} /Descent {} /CapHeight {} /StemV 80 /FontFile2 7 0 R >>", + milli(m.bbox[0] as f64), + milli(m.bbox[1] as f64), + milli(m.bbox[2] as f64), + milli(m.bbox[3] as f64), + milli(m.ascent as f64), + milli(m.descent as f64), + milli(m.cap_height as f64), + ) + .as_bytes(), + ); + let subset = f.subset(&glyphs.keys().copied().collect())?; + file.add_stream(&format!(" /Length1 {}", subset.len()), &subset); + + let mut map = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\ + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\ + /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\ + 1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let pairs: Vec<(&u16, &char)> = glyphs.iter().collect(); + // A block holds at most 100 lines. + for block in pairs.chunks(100) { + let _ = writeln!(map, "{} beginbfchar", block.len()); + for (glyph, c) in block { + let _ = write!(map, "<{glyph:04X}> <"); + for unit in c.encode_utf16(&mut [0; 2]) { + let _ = write!(map, "{unit:04X}"); + } + map.push_str(">\n"); + } + map.push_str("endbfchar\n"); + } + map.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend"); + file.add_stream("", map.as_bytes()); + Ok(()) + } +} + +/// Test support: read back what the writer wrote, through the file's own tables. +#[cfg(test)] +mod reading { + use std::collections::BTreeMap; + + fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|w| w == needle) + .map(|i| i + from) + } + + fn lossy(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() + } + + /// Object `n`'s bytes, from after `n 0 obj` to `endobj`, found through the xref table. + fn object(bytes: &[u8], n: usize) -> &[u8] { + let s = lossy(bytes); + let at = s.rfind("startxref\n").expect("startxref") + 10; + let xref: usize = s[at..].lines().next().unwrap().parse().unwrap(); + let entries = find(bytes, b"\n", xref + 5).unwrap() + 1; + let entry = &bytes[entries + 20 * n..entries + 20 * n + 10]; + let offset: usize = lossy(entry).parse().unwrap(); + let start = find(bytes, b"obj\n", offset).unwrap() + 4; + // A stream's length says where it ends; binary data may hold the word "endobj". + let end = match stream_range(&bytes[start..]) { + Some((_, end)) => find(bytes, b"endobj", start + end).unwrap(), + None => find(bytes, b"endobj", start).unwrap(), + }; + &bytes[start..end] + } + + /// Where an object's stream data starts and ends, when it has one. + fn stream_range(object: &[u8]) -> Option<(usize, usize)> { + if !object.starts_with(b"<< /Length ") { + return None; + } + let len: usize = lossy(&object[11..find(object, b" ", 11)?]) + .trim() + .parse() + .ok()?; + let start = find(object, b">>\nstream\n", 0)? + 10; + Some((start, start + len)) + } + + fn stream(bytes: &[u8], n: usize) -> Vec { + let obj = object(bytes, n); + let (start, end) = stream_range(obj).expect("the object is a stream"); + obj[start..end].to_vec() + } + + /// The number before ` 0 R` that follows `key` in an object. + fn reference(object: &[u8], key: &str) -> usize { + let s = lossy(object); + let rest = &s[s.find(key).unwrap_or_else(|| panic!("{key} in {s}")) + key.len()..]; + rest.split_whitespace().next().unwrap().parse().unwrap() + } + + /// Glyph id to text, from the file's ToUnicode map; empty when the file embeds no font. + fn to_unicode(bytes: &[u8]) -> BTreeMap { + let mut map = BTreeMap::new(); + let s = lossy(bytes); + let Some(at) = s.find("/ToUnicode ") else { + return map; + }; + let n: usize = s[at + 11..] + .split_whitespace() + .next() + .unwrap() + .parse() + .unwrap(); + for line in lossy(&stream(bytes, n)).lines() { + let Some((glyph, text)) = line.strip_prefix('<').and_then(|l| l.split_once("> <")) + else { + continue; + }; + let Some(text) = text.strip_suffix('>') else { + continue; + }; + if glyph.len() != 4 || text.len() % 4 != 0 || glyph == "0000" && text == "FFFF" { + continue; + } + let units: Vec = (0..text.len()) + .step_by(4) + .map(|i| u16::from_str_radix(&text[i..i + 4], 16).unwrap()) + .collect(); + map.insert( + u16::from_str_radix(glyph, 16).unwrap(), + String::from_utf16(&units).unwrap(), + ); + } + map + } + + /// The strings one content stream shows, decoded through `map`. + fn strings(content: &[u8], map: &BTreeMap) -> Vec { + lossy(content) + .lines() + .filter_map(|line| { + let hex = &line[line.find('<')? + 1..line.find("> Tj")?]; + Some( + (0..hex.len()) + .step_by(4) + .map(|i| map[&u16::from_str_radix(&hex[i..i + 4], 16).unwrap()].as_str()) + .collect(), + ) + }) + .collect() + } + + /// Every string shown with `Tj` anywhere in the file (forms and pages), decoded through the + /// file's own ToUnicode map, in file order. + pub(crate) fn shown_text(bytes: &[u8]) -> Vec { + let map = to_unicode(bytes); + let s = lossy(bytes); + let size: usize = s[s.rfind("/Size ").unwrap() + 6..] + .split_whitespace() + .next() + .unwrap() + .parse() + .unwrap(); + (1..size) + .filter_map(|n| { + let obj = object(bytes, n); + // The font file is a stream too, but binary: only content streams show text. + let is_content = !lossy(&obj[..obj.len().min(200)]).contains("/Length1"); + stream_range(obj) + .filter(|_| is_content) + .map(|(a, b)| strings(&obj[a..b], &map)) + }) + .flatten() + .collect() + } + + /// One page's own content stream, for comparing pages between two documents. `page` counts + /// from 0. + pub(crate) fn page_stream(bytes: &[u8], page: usize) -> Vec { + let tree = lossy(object(bytes, 2)); + let kids = &tree[tree.find('[').unwrap() + 1..tree.find(']').unwrap()]; + let n: usize = kids + .split(" 0 R") + .nth(page) + .unwrap_or_else(|| panic!("page {page} of {kids}")) + .trim() + .parse() + .unwrap(); + stream(bytes, reference(object(bytes, n), "/Contents ")) + } + + /// The strings shown by one page's own content stream (not its forms). `page` counts from 0. + pub(crate) fn page_text(bytes: &[u8], page: usize) -> Vec { + strings(&page_stream(bytes, page), &to_unicode(bytes)) + } +} + +#[cfg(test)] +pub(crate) use reading::{page_stream, page_text, shown_text}; + +#[cfg(test)] +mod tests { + use super::*; + + const BLACK: Stroke = Stroke { + width_mm: 0.35, + grey: 0.0, + dash_mm: None, + }; + + fn a4() -> (f64, f64) { + (210.0 * PT_PER_MM, 297.0 * PT_PER_MM) + } + + fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() + } + + fn count(bytes: &[u8], needle: &str) -> usize { + text(bytes).matches(needle).count() + } + + /// A page with one line of text on it. + fn page_saying(pdf: &mut Pdf, words: &str) { + let mut c = Content::new(); + c.text((100.0, 100.0), 12.0, 0.0, Anchor::Start, 0.0, words); + let (w, h) = a4(); + pdf.add_page(w, h, c); + } + + /// Reads `startxref`, then checks that every in-use entry points at its own object. + fn assert_xref_is_right(bytes: &[u8]) { + let s = text(bytes); + assert!(s.starts_with("%PDF-1.4\n")); + assert!(s.ends_with("%%EOF\n")); + let start: usize = s[s.rfind("startxref\n").unwrap() + 10..] + .lines() + .next() + .unwrap() + .parse() + .unwrap(); + let table = &bytes[start..]; + assert!(table.starts_with(b"xref\n0 ")); + let header_end = 5 + table[5..].iter().position(|b| *b == b'\n').unwrap() + 1; + let n: usize = text(&table[7..header_end - 1]).parse().unwrap(); + assert!(n >= 4); + assert_eq!( + &table[header_end..header_end + 20], + b"0000000000 65535 f \n" + ); + for i in 1..n { + let entry = &table[header_end + 20 * i..header_end + 20 * (i + 1)]; + assert_eq!(&entry[10..], b" 00000 n \n", "entry {i}"); + let offset: usize = text(&entry[..10]).parse().unwrap(); + let expect = format!("{i} 0 obj\n"); + assert!( + bytes[offset..].starts_with(expect.as_bytes()), + "object {i} at {offset}" + ); + } + assert!(s.contains(&format!("/Size {n} "))); + } + + #[test] + fn xref_offsets_land_on_their_objects() { + let mut pdf = Pdf::new("Two pages"); + page_saying(&mut pdf, "one"); + page_saying(&mut pdf, "two"); + assert_eq!(pdf.page_count(), 2); + let doc = pdf.finish().unwrap(); + assert_eq!(doc.pages, 2); + assert_xref_is_right(&doc.bytes); + assert_eq!(count(&doc.bytes, "/Type /Page "), 2); + assert!(text(&doc.bytes).contains("/Count 2")); + } + + /// 210 mm and 297 mm at 72 / 25.4 pt per mm: 595.2756 and 841.8898. + #[test] + fn an_a4_page_has_the_a4_media_box() { + let mut pdf = Pdf::new("A4"); + let (w, h) = a4(); + pdf.add_page(w, h, Content::new()); + let doc = pdf.finish().unwrap(); + assert!(text(&doc.bytes).contains("/MediaBox [0 0 595.276 841.890]")); + } + + /// 1 pt = 1/72 in and 1 in = 25.4 mm by definition, so 10 mm = 28.346 pt and 110 mm = 311.811 pt. + #[test] + fn a_100_mm_line_spans_283_465_pt() { + let mut c = Content::new(); + c.polyline( + &[ + (10.0 * PT_PER_MM, 10.0 * PT_PER_MM), + (110.0 * PT_PER_MM, 10.0 * PT_PER_MM), + ], + false, + BLACK, + ); + let mut pdf = Pdf::new("Line"); + let (w, h) = a4(); + pdf.add_page(w, h, c); + let doc = pdf.finish().unwrap(); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!( + stream.contains("28.346 28.346 m\n311.811 28.346 l\nS\n"), + "{stream}" + ); + // 0.35 mm is 0.992 pt. + assert!(stream.contains("0.992 w"), "{stream}"); + assert!(stream.contains("[] 0 d"), "{stream}"); + } + + #[test] + fn a_dashed_closed_grey_path_says_so() { + let mut c = Content::new(); + let dashed = Stroke { + width_mm: 0.18, + grey: 0.55, + dash_mm: Some((2.0, 1.0)), + }; + c.polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], true, dashed); + // A path of fewer than two points draws nothing. + c.polyline(&[(5.0, 5.0)], false, BLACK); + c.polyline(&[(f64::NAN, 5.0), (1.0, 1.0)], false, BLACK); + let mut pdf = Pdf::new("Dash"); + pdf.add_page(100.0, 100.0, c); + let doc = pdf.finish().unwrap(); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!(stream.contains("0.550 G"), "{stream}"); + assert!(stream.contains("[5.669 2.835] 0 d"), "{stream}"); + assert!(stream.contains("10.000 10.000 l\nh\nS\n"), "{stream}"); + assert_eq!(stream.matches("S\n").count(), 1, "{stream}"); + assert!(!stream.contains("NaN"), "{stream}"); + } + + #[test] + fn the_same_input_gives_the_same_bytes() { + let build = || { + let mut pdf = Pdf::new("Same"); + page_saying(&mut pdf, "Station 3"); + pdf.finish().unwrap().bytes + }; + let bytes = build(); + assert_eq!(bytes, build()); + assert!(!text(&bytes).contains("/CreationDate")); + assert!(!text(&bytes).contains("/ID")); + let producer = format!("/Producer (SeaCraft {})", crate::VERSION); + assert!(text(&bytes).contains(&producer)); + } + + #[test] + fn a_form_used_on_three_pages_is_written_once() { + let mut pdf = Pdf::new("Forms"); + let mut drawing = Content::new(); + drawing.polyline(&[(0.0, 0.0), (50.0, 50.0)], false, BLACK); + drawing.text((10.0, 10.0), 8.0, 0.0, Anchor::Start, 0.0, "in the form"); + let form = pdf.add_form([0.0, 0.0, 50.0, 50.0], drawing); + for i in 0..3 { + let mut c = Content::new(); + c.save(); + c.clip_rect(10.0, 10.0, 80.0, 80.0); + c.translate(20.0 * i as f64, 0.0); + c.form(form); + c.restore(); + pdf.add_page(100.0, 100.0, c); + } + let doc = pdf.finish().unwrap(); + assert_eq!(count(&doc.bytes, "/Subtype /Form"), 1); + assert_eq!(count(&doc.bytes, " Do\n"), 3); + assert!(text(&doc.bytes).contains("/BBox [0.000 0.000 50.000 50.000]")); + let second = text(&page_stream(&doc.bytes, 1)); + assert_eq!( + second, + "q\n10.000 10.000 80.000 80.000 re W n\n1 0 0 1 20.000 0.000 cm\n/X0 Do\nQ\n" + ); + // The text is in the form, not on the pages, and is found either way. + assert_eq!(shown_text(&doc.bytes), ["in the form"]); + assert!(page_text(&doc.bytes, 0).is_empty()); + assert_xref_is_right(&doc.bytes); + } + + #[test] + fn turkish_text_is_embedded_and_searchable() { + let words = "Kayık Şile ğüöç İ"; + let mut pdf = Pdf::new("Türkçe"); + page_saying(&mut pdf, words); + let doc = pdf.finish().unwrap(); + let s = text(&doc.bytes); + for needle in [ + "/Identity-H", + "/FontFile2", + "/ToUnicode", + "/AAAAAA+DejaVuSans", + ] { + assert!(s.contains(needle), "{needle}"); + } + for needle in [ + "/CIDFontType2", + "/CIDToGIDMap /Identity", + "/Flags 4", + "/DW 1000", + ] { + assert!(s.contains(needle), "{needle}"); + } + assert_eq!(shown_text(&doc.bytes), [words]); + assert_eq!(page_text(&doc.bytes, 0), [words]); + assert!(doc.missing.is_empty()); + assert_xref_is_right(&doc.bytes); + // The embedded font is the subset, not the 757 kB file. + assert!(doc.bytes.len() < 100_000, "{}", doc.bytes.len()); + } + + #[test] + fn a_character_the_font_lacks_becomes_a_question_mark_and_is_reported() { + // U+8239 (a Chinese character) and U+1F6A2 (an emoji), neither in DejaVu Sans. + let mut pdf = Pdf::new("Missing"); + page_saying(&mut pdf, "\u{8239} A"); + page_saying(&mut pdf, "\u{1F6A2}\u{8239}"); + let doc = pdf.finish().unwrap(); + assert_eq!(doc.missing, ['\u{8239}', '\u{1F6A2}']); + assert_eq!(shown_text(&doc.bytes), ["? A", "??"]); + assert_xref_is_right(&doc.bytes); + } + + #[test] + fn a_title_with_brackets_and_turkish_survives() { + let mut pdf = Pdf::new("a(b)\\c Şile"); + page_saying(&mut pdf, "x"); + let doc = pdf.finish().unwrap(); + assert_xref_is_right(&doc.bytes); + // UTF-16BE with a byte order mark, as hex: FEFF, then "a(b)\c Şile". + let s = text(&doc.bytes); + let hex = "FEFF 0061 0028 0062 0029 005C 0063 0020 015E 0069 006C 0065".replace(' ', ""); + assert!( + s.contains(&format!("/Title <{hex}> ")), + "{}", + &s[s.find("/Title").unwrap()..][..80] + ); + } + + #[test] + fn a_page_over_5_08_m_is_an_error() { + let mut pdf = Pdf::new("Big"); + pdf.add_page(14_401.0, 100.0, Content::new()); + let err = pdf.finish().unwrap_err(); + assert!(err.contains("5.08 m"), "{err}"); + let mut pdf = Pdf::new("Just fits"); + pdf.add_page(MAX_PAGE_PT, MAX_PAGE_PT, Content::new()); + assert!(pdf.finish().is_ok()); + } + + #[test] + fn a_page_with_no_size_is_an_error() { + for (w, h) in [ + (0.0, 100.0), + (f64::NAN, 100.0), + (100.0, -5.0), + (100.0, f64::INFINITY), + ] { + let mut pdf = Pdf::new("Bad"); + pdf.add_page(w, h, Content::new()); + assert!(pdf.finish().is_err(), "{w} x {h}"); + } + assert!( + Pdf::new("Empty").finish().is_err(), + "a document needs a page" + ); + } + + #[test] + fn a_document_without_text_embeds_no_font() { + let mut c = Content::new(); + c.polyline(&[(0.0, 0.0), (10.0, 10.0)], false, BLACK); + c.text((0.0, 0.0), 10.0, 0.0, Anchor::Start, 0.0, ""); + let mut pdf = Pdf::new("Lines only"); + pdf.add_page(100.0, 100.0, c); + let doc = pdf.finish().unwrap(); + assert!(!text(&doc.bytes).contains("/FontFile2")); + assert!(!text(&doc.bytes).contains("/Font")); + assert_xref_is_right(&doc.bytes); + } + + /// The width is the sum of the font's own advances, read here a second way through `ttf`. + #[test] + fn anchors_shift_text_by_its_width() { + let font = crate::ttf::Font::parse(crate::fonts::DEJAVU_SANS).unwrap(); + let zero = font.advance(font.glyph('0').unwrap()) as f64; + let width = 3.0 * zero / font.metrics().units_per_em as f64 * 10.0; + assert!((text_width_pt("000", 10.0) - width).abs() < 1e-9); + assert!(width > 15.0 && width < 25.0, "{width}"); + assert_eq!(text_width_pt("", 10.0), 0.0); + + let stream = |anchor, angle| { + let mut c = Content::new(); + c.text((100.0, 50.0), 10.0, angle, anchor, 0.5, "000"); + let mut pdf = Pdf::new("Anchor"); + pdf.add_page(200.0, 200.0, c); + text(&page_stream(&pdf.finish().unwrap().bytes, 0)) + }; + let at = |x: f64, y: f64| format!("1 0 0 1 {x:.3} {y:.3} Tm"); + assert!(stream(Anchor::Start, 0.0).contains(&at(100.0, 50.0))); + assert!(stream(Anchor::Middle, 0.0).contains(&at(100.0 - width / 2.0, 50.0))); + assert!(stream(Anchor::End, 0.0).contains(&at(100.0 - width, 50.0))); + assert!(stream(Anchor::Start, 0.0).contains("0.500 g")); + // Turned a quarter turn, the text runs up the page and the anchor shifts it down. + let up = stream(Anchor::End, 90.0); + assert!( + up.contains(&format!("0 1 -1 0 100.000 {:.3} Tm", 50.0 - width)), + "{up}" + ); + } +} diff --git a/crates/seacraft-core/tests/pdf_render.rs b/crates/seacraft-core/tests/pdf_render.rs new file mode 100644 index 0000000..9a45278 --- /dev/null +++ b/crates/seacraft-core/tests/pdf_render.rs @@ -0,0 +1,129 @@ +//! The PDF writer against poppler, an independent PDF implementation. Ignored by default because +//! it needs `pdftoppm` and `pdftotext` on the PATH; run it with +//! `cargo test -p seacraft-core --test pdf_render -- --ignored --nocapture`. + +use std::path::Path; +use std::process::Command; + +use seacraft_core::pdf::{Anchor, Content, Pdf, Stroke, PT_PER_MM}; + +/// A binary PGM's width, height and pixels. +fn read_pgm(path: &Path) -> (usize, usize, Vec) { + let data = std::fs::read(path).unwrap(); + // "P5", width, height and the largest value, each ended by white space, then the pixels. + let mut fields = Vec::new(); + let mut at = 0; + while fields.len() < 4 { + let start = at; + while !data[at].is_ascii_whitespace() { + at += 1; + } + fields.push(String::from_utf8_lossy(&data[start..at]).into_owned()); + at += 1; + } + assert_eq!(fields[0], "P5"); + assert_eq!(fields[3], "255"); + let (w, h): (usize, usize) = (fields[1].parse().unwrap(), fields[2].parse().unwrap()); + (w, h, data[at..at + w * h].to_vec()) +} + +/// Renders with poppler, an independent implementation, at 254 dpi so that 1 px = 0.1 mm. +/// A4 page; a 0.35 mm line from (50, 150) mm to (150, 150) mm; "Kayık Şile" at (50, 200) mm, 12 pt. +/// `pdftoppm -r 254 -gray -f 1 -l 1` gives a binary PGM, 2100 x 2970 or a pixel more. Rows are counted from the +/// top, so the line is on row 1470: over rows 1468..=1472 the largest count of pixels darker than +/// 128 must be 1000 +/- 2. `pdftotext FILE -` must contain "Kayık Şile". +/// Returns early with a message on stderr when `pdftoppm` is not on PATH. +#[test] +#[ignore = "needs poppler (pdftoppm, pdftotext)"] +fn poppler_draws_100_mm_as_1000_pixels_and_reads_turkish_text() { + if Command::new("pdftoppm").arg("-v").output().is_err() { + eprintln!("pdftoppm is not on PATH: nothing was checked"); + return; + } + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")); + let file = dir.join("pdf_render.pdf"); + let mut c = Content::new(); + let mm = PT_PER_MM; + let line = Stroke { + width_mm: 0.35, + grey: 0.0, + dash_mm: None, + }; + c.polyline( + &[(50.0 * mm, 150.0 * mm), (150.0 * mm, 150.0 * mm)], + false, + line, + ); + c.text( + (50.0 * mm, 200.0 * mm), + 12.0, + 0.0, + Anchor::Start, + 0.0, + "Kayık Şile", + ); + let mut pdf = Pdf::new("Render check"); + pdf.add_page(210.0 * mm, 297.0 * mm, c); + std::fs::write(&file, pdf.finish().unwrap().bytes).unwrap(); + + let prefix = dir.join("pdf_render"); + let out = Command::new("pdftoppm") + .args(["-r", "254", "-gray", "-f", "1", "-l", "1", "-singlefile"]) + .arg(&file) + .arg(&prefix) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let (w, h, px) = read_pgm(&prefix.with_extension("pgm")); + // The page is 2100.001 x 2970.002 px, three decimals of a point being what they are, and + // poppler rounds a page up to whole pixels. + assert!( + (2100..=2101).contains(&w) && (2970..=2971).contains(&h), + "{w} x {h}" + ); + let dark = |row: usize| { + px[row * w..(row + 1) * w] + .iter() + .filter(|p| **p < 128) + .count() + }; + let longest = (1468..=1472).map(dark).max().unwrap(); + assert!( + (998..=1002).contains(&longest), + "{longest} px, expected 1000" + ); + // The line starts 50 mm from the left edge: pixel 500. + let first = px[1470 * w..1471 * w] + .iter() + .position(|p| *p < 128) + .unwrap(); + assert!((499..=501).contains(&first), "{first}"); + // Nothing is drawn well above or below the line. + assert_eq!(dark(1460) + dark(1480), 0); + + let out = Command::new("pdftotext") + .arg(&file) + .arg("-") + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let words = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!(words.contains("Kayık Şile"), "{words:?}"); + eprintln!( + "poppler: 100 mm drew as {longest} px at 254 dpi; text read back as {:?}", + words.trim() + ); +} From f4cfeaf8cb2c1b53804b16818f8438b55e4ee168 Mon Sep 17 00:00:00 2001 From: Ekrem Seren <18965+havan@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:45:26 +0300 Subject: [PATCH 03/31] feat(core): lengths as text, metric first with imperial beside it --- crates/seacraft-core/src/lib.rs | 2 + crates/seacraft-core/src/units.rs | 186 ++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 crates/seacraft-core/src/units.rs diff --git a/crates/seacraft-core/src/lib.rs b/crates/seacraft-core/src/lib.rs index 80d2723..7f6ec79 100644 --- a/crates/seacraft-core/src/lib.rs +++ b/crates/seacraft-core/src/lib.rs @@ -73,6 +73,8 @@ pub mod transform; pub mod trimopt; /// Read a TrueType font and write a subset of it. pub mod ttf; +/// Lengths as text: metric first, imperial beside it when asked. +pub mod units; pub mod weather; pub use analysis::AnalysisMode; diff --git a/crates/seacraft-core/src/units.rs b/crates/seacraft-core/src/units.rs new file mode 100644 index 0000000..a3a35af --- /dev/null +++ b/crates/seacraft-core/src/units.rs @@ -0,0 +1,186 @@ +//! Lengths as text, for labels and tables a builder reads. +//! +//! The program works in metres. Metric is always shown, and first; imperial is a second reading +//! beside it, never a replacement, so turning it on changes no metric number. +//! +//! Imperial readings are to the nearest sixteenth of an inch, with 1 in = 25.4 mm exactly. + +/// Metres in one inch, exactly, by the 1959 international definition. +pub const M_PER_IN: f64 = 0.0254; + +/// A number with `decimals` places, and no minus sign on a zero. +fn fixed(v: f64, decimals: usize) -> String { + let s = format!("{v:.decimals$}"); + match s.strip_prefix('-') { + Some(rest) if rest.chars().all(|c| c == '0' || c == '.') => rest.to_owned(), + _ => s, + } +} + +/// Metres to the millimetre: `2.400 m`. +pub fn metres(m: f64) -> String { + format!("{} m", fixed(m, 3)) +} + +/// Whole millimetres with no unit, for a table whose heading names it: `2400`. +pub fn millimetres(m: f64) -> String { + fixed(m * 1000.0, 0) +} + +/// Millimetres to a tenth, for a thickness: `6.0 mm`. +pub fn mm_tenths(m: f64) -> String { + format!("{} mm", fixed(m * 1000.0, 1)) +} + +/// A length as a sign, whole feet, whole inches and sixteenths of an inch, rounded to the +/// nearest sixteenth. `None` when the length is not a finite number. +fn sixteenths(m: f64) -> Option<(&'static str, u64, u64, u64)> { + if !m.is_finite() { + return None; + } + let total = (m.abs() / M_PER_IN * 16.0).round() as u64; + let sign = if m < 0.0 && total > 0 { "-" } else { "" }; + Some((sign, total / 192, total / 16 % 12, total % 16)) +} + +/// Feet, inches and a fraction, to the nearest 1/16 in: `7' 10 1/2"`. The fraction is in its +/// lowest terms, and the feet are left out when there are none: `11 13/16"`. +pub fn ft_in(m: f64) -> String { + let Some((sign, feet, inches, sixteenths)) = sixteenths(m) else { + return "-".to_owned(); + }; + let fraction = match sixteenths { + 0 => String::new(), + s => { + let d = 1 << s.trailing_zeros(); + format!("{}/{}", s / d, 16 / d) + } + }; + let inches = match (feet, inches, fraction.is_empty()) { + (0, 0, false) => fraction, + (_, _, true) => inches.to_string(), + (_, _, false) => format!("{inches} {fraction}"), + }; + if feet == 0 { + format!("{sign}{inches}\"") + } else { + format!("{sign}{feet}' {inches}\"") + } +} + +/// Feet, inches and eighths as boatbuilders' tables write them, to the nearest 1/16 in: +/// `7-10-4` is 7 ft 10 4/8 in, and a trailing `+` is one sixteenth more. +pub fn ft_in_8ths(m: f64) -> String { + let Some((sign, feet, inches, sixteenths)) = sixteenths(m) else { + return "-".to_owned(); + }; + let plus = if sixteenths % 2 == 1 { "+" } else { "" }; + format!("{sign}{feet}-{inches}-{}{plus}", sixteenths / 2) +} + +/// A length for a label: metres, and with `imperial` the feet and inches after it in brackets, +/// `2.400 m (7' 10 1/2")`. +pub fn length(m: f64, imperial: bool) -> String { + if imperial { + format!("{} ({})", metres(m), ft_in(m)) + } else { + metres(m) + } +} + +/// A number with up to three decimals and no trailing zeros. +fn short(v: f64) -> String { + let s = format!("{v:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_owned() +} + +/// A drawing scale as a ratio: `1:1`, `1:10` for 0.1, `2:1` for 2. +pub fn scale_label(scale: f64) -> String { + if scale >= 1.0 { + format!("{}:1", short(scale)) + } else { + format!("1:{}", short(1.0 / scale)) + } +} + +/// Read a scale written as a ratio, `1:10`, or as one number, `0.1`. Both sides must be more +/// than zero. +pub fn parse_scale(text: &str) -> Result { + let bad = || { + format!( + "`{}` is not a scale: write it as 1:10, or 1 for full size", + text.trim() + ) + }; + let number = |part: &str| -> Result { + part.trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v > 0.0) + .ok_or_else(bad) + }; + let scale = match text.split(':').collect::>()[..] { + [one] => number(one)?, + [drawn, real] => number(drawn)? / number(real)?, + _ => return Err(bad()), + }; + if scale.is_finite() && scale > 0.0 { + Ok(scale) + } else { + Err(bad()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Hand-computed from 1 in = 25.4 mm exactly; each value rounded to the nearest sixteenth of + /// an inch. + #[test] + fn imperial_readings() { + for (m, eighths, feet) in [ + (0.3048, "1-0-0", "1' 0\""), // 12 in + (1.0, "3-3-3", "3' 3 3/8\""), // 39.370 in -> 630/16 + (2.4, "7-10-4", "7' 10 1/2\""), // 94.488 in -> 1512/16 + (0.3, "0-11-6+", "11 13/16\""), // 11.811 in -> 189/16 + (0.30475, "1-0-0", "1' 0\""), // 191.97/16 carries to 12 in + (0.0, "0-0-0", "0\""), + (-0.05, "-0-1-7+", "-1 15/16\""), // 1.9685 in -> 31/16 + (0.01, "0-0-3", "3/8\""), // 0.3937 in -> 6/16 + (0.3148, "1-0-3", "1' 0 3/8\""), // 12.394 in -> 198/16 + (-0.0001, "0-0-0", "0\""), // rounds to nothing, so no sign + ] { + assert_eq!(ft_in_8ths(m), eighths, "{m}"); + assert_eq!(ft_in(m), feet, "{m}"); + } + } + + #[test] + fn metric_comes_first_and_alone_by_default() { + assert_eq!(length(2.4, false), "2.400 m"); + assert_eq!(length(2.4, true), "2.400 m (7' 10 1/2\")"); + assert_eq!(metres(-0.0001), "0.000 m"); + assert_eq!(millimetres(2.0), "2000"); + assert_eq!(millimetres(-0.0004), "0"); + assert_eq!(millimetres(-0.0126), "-13"); + assert_eq!(mm_tenths(0.006), "6.0 mm"); + assert_eq!(mm_tenths(0.01234), "12.3 mm"); + } + + #[test] + fn scales() { + assert_eq!( + (scale_label(1.0), scale_label(0.1), scale_label(2.0)), + ("1:1".into(), "1:10".into(), "2:1".into()) + ); + assert_eq!(scale_label(0.4), "1:2.5"); + assert_eq!(parse_scale("1:10"), Ok(0.1)); + assert_eq!(parse_scale("1"), Ok(1.0)); + assert_eq!(parse_scale(" 2:1 "), Ok(2.0)); + assert_eq!(parse_scale(&scale_label(0.05)), Ok(0.05)); + for bad in ["1:0", "0:1", "x", "", "1:-2", "1:2:3", "nan", "inf:1", "0"] { + assert!(parse_scale(bad).is_err(), "{bad}"); + } + } +} From abbe22381858fd68fce6c89bb26202af54b6c99b Mon Sep 17 00:00:00 2001 From: Ekrem Seren <18965+havan@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:53:00 +0300 Subject: [PATCH 04/31] feat(core): a drawing sheet with single-page PDF, SVG and DXF writers --- crates/seacraft-core/src/dxf.rs | 79 +- crates/seacraft-core/src/lib.rs | 2 + crates/seacraft-core/src/pdf.rs | 55 ++ crates/seacraft-core/src/sheet.rs | 1118 +++++++++++++++++++++++++++++ 4 files changed, 1247 insertions(+), 7 deletions(-) create mode 100644 crates/seacraft-core/src/sheet.rs diff --git a/crates/seacraft-core/src/dxf.rs b/crates/seacraft-core/src/dxf.rs index 6dac51d..a3dd957 100644 --- a/crates/seacraft-core/src/dxf.rs +++ b/crates/seacraft-core/src/dxf.rs @@ -14,6 +14,8 @@ //! //! Units: SeaCraft measures in metres, so the header declares `$INSUNITS` 6 //! (metres) and metric measurement rather than the original's millimetres. +//! [`write_dxf_in`] writes a drawing whose numbers are millimetres, for +//! full-size patterns sent to a cutting machine. use crate::math::Vec3; use std::fmt::Write as _; @@ -97,6 +99,10 @@ pub enum DxfShape { Polyline { points: Vec, }, + /// A closed outline: the last point joins the first. + ClosedPolyline { + points: Vec, + }, /// A 3D polyline (`POLYLINE` with 3D vertices), for hull curves in place. Polyline3 { points: Vec, @@ -127,7 +133,9 @@ impl DxfShape { fn sample(&self, out: &mut Vec) { match self { DxfShape::Line(a, b) => out.extend([*a, *b]), - DxfShape::Polyline { points } => out.extend(points.iter().copied()), + DxfShape::Polyline { points } | DxfShape::ClosedPolyline { points } => { + out.extend(points.iter().copied()) + } DxfShape::Polyline3 { points } => { out.extend(points.iter().map(|p| Vec2::new(p.x, p.y))) } @@ -274,9 +282,25 @@ fn text_value(s: &str) -> String { .collect() } +/// The unit a drawing's numbers are in, declared in its header. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfUnits { + /// `$INSUNITS` 6, what every hull drawing uses. + #[default] + Metres, + /// `$INSUNITS` 4, for full-size patterns: cutting machines expect millimetres. + Millimetres, +} + /// A DXF drawing (AutoCAD 2000, metres) of `entities`. `layers` are the layers /// they are on; layer `0` is always present. pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { + write_dxf_in(DxfUnits::Metres, layers, entities) +} + +/// [`write_dxf`] for a drawing whose numbers are in `units`. The caller gives +/// every coordinate and text height in that unit; nothing is converted here. +pub fn write_dxf_in(units: DxfUnits, layers: &[DxfLayer], entities: &[DxfEntity]) -> String { let mut pts = Vec::new(); for e in entities { e.shape.sample(&mut pts); @@ -386,7 +410,7 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { for e in entities { let kind = match &e.shape { DxfShape::Line(..) => "LINE", - DxfShape::Polyline { .. } => "LWPOLYLINE", + DxfShape::Polyline { .. } | DxfShape::ClosedPolyline { .. } => "LWPOLYLINE", DxfShape::Polyline3 { .. } => "POLYLINE", DxfShape::Circle { .. } => "CIRCLE", DxfShape::Arc { .. } => "ARC", @@ -406,11 +430,12 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { }, ); match &e.shape { - DxfShape::Polyline { points } => { + DxfShape::Polyline { points } | DxfShape::ClosedPolyline { points } => { o.code(100, "AcDbPolyline"); o.code(90, points.len()); - // Bit 128 (PLINEGEN) continues the dash pattern. - o.code(70, 128); + // Bit 128 (PLINEGEN) continues the dash pattern; bit 1 closes the outline. + let closed = matches!(e.shape, DxfShape::ClosedPolyline { .. }); + o.code(70, 128 + i32::from(closed)); for p in points { o.num(10, p.x); o.num(20, p.y); @@ -519,9 +544,15 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { head.code(1, "AC1015"); head.code(9, "$HANDSEED"); head.code(5, format!("{:X}", o.next)); - // 6 = metres; measurement 1 = metric. + // 6 = metres, 4 = millimetres; measurement 1 = metric. head.code(9, "$INSUNITS"); - head.code(70, 6); + head.code( + 70, + match units { + DxfUnits::Metres => 6, + DxfUnits::Millimetres => 4, + }, + ); head.code(9, "$MEASUREMENT"); head.code(70, 1); head.code(9, "$EXTMIN"); @@ -748,4 +779,38 @@ mod tests { }); assert!(counts.get(&0).copied().unwrap_or(0) > 10); } + /// A closed outline is one LWPOLYLINE with the closed bit (1) beside PLINEGEN (128), and a + /// drawing in millimetres declares `$INSUNITS` 4, as Autodesk's DXF reference numbers them. + #[test] + fn a_closed_outline_in_millimetres_says_both() { + let square = vec![ + Vec2::new(0.0, 0.0), + Vec2::new(1000.0, 0.0), + Vec2::new(1000.0, 500.0), + Vec2::new(0.0, 500.0), + ]; + let items = [DxfEntity::new( + "CUT", + DxfShape::ClosedPolyline { points: square }, + )]; + let dxf = write_dxf_in(DxfUnits::Millimetres, &[DxfLayer::new("CUT", 7)], &items); + let e = entities(&dxf); + assert_eq!(e[0].0, "LWPOLYLINE"); + assert_eq!(values(&e[0], 90), [4.0]); + assert_eq!(values(&e[0], 70), [129.0]); + assert_eq!(values(&e[0], 10), [0.0, 1000.0, 1000.0, 0.0]); + let p = pairs(&dxf); + let units = p + .iter() + .position(|(c, v)| (*c, v.as_str()) == (9, "$INSUNITS")) + .unwrap(); + assert_eq!(p[units + 1], (70, "4".to_string())); + let ext = p + .iter() + .position(|(c, v)| (*c, v.as_str()) == (9, "$EXTMAX")) + .unwrap(); + assert_eq!(p[ext + 1], (10, "1000.0".to_string())); + // The plain call still writes metres. + assert!(write_dxf(&[], &items).contains("$INSUNITS\n 70\n6\n")); + } } diff --git a/crates/seacraft-core/src/lib.rs b/crates/seacraft-core/src/lib.rs index 7f6ec79..103c809 100644 --- a/crates/seacraft-core/src/lib.rs +++ b/crates/seacraft-core/src/lib.rs @@ -64,6 +64,8 @@ pub mod resistance; pub mod seakeeping; /// Irregular head-sea responses: spectra, significant motions, accelerations, MSI. pub mod seastate; +/// A 2D drawing in metres and its writers: tiled PDF, single-page PDF, SVG, DXF. +pub mod sheet; pub mod stability; pub mod subdiv; pub mod table; diff --git a/crates/seacraft-core/src/pdf.rs b/crates/seacraft-core/src/pdf.rs index 4fedf50..c1f4489 100644 --- a/crates/seacraft-core/src/pdf.rs +++ b/crates/seacraft-core/src/pdf.rs @@ -111,6 +111,22 @@ pub fn text_width_pt(text: &str, size_pt: f64) -> f64 { units as f64 / f.metrics().units_per_em as f64 * size_pt } +/// `text` broken into lines no wider than `width_pt` at `size_pt`, at spaces. A word wider than +/// the line gets a line to itself. An empty text is no lines. +pub fn wrap_text(text: &str, size_pt: f64, width_pt: f64) -> Vec { + let mut lines: Vec = Vec::new(); + for word in text.split_whitespace() { + match lines.last_mut() { + Some(line) if text_width_pt(&format!("{line} {word}"), size_pt) <= width_pt => { + line.push(' '); + line.push_str(word); + } + _ => lines.push(word.to_owned()), + } + } + lines +} + /// What goes on a page or in a reusable drawing: lines, text and placed drawings, in the order /// they are added. Coordinates are points, x to the right and y up, from the lower-left corner. #[derive(Default, Clone, Debug)] @@ -242,6 +258,16 @@ impl Content { ); } + /// Add everything in `other` after what is here, as if it had been drawn here. + pub fn append(&mut self, other: &Content) { + self.ops.push_str(&other.ops); + for (glyph, c) in &other.glyphs { + self.glyphs.entry(*glyph).or_insert(*c); + } + self.missing.extend(&other.missing); + self.forms.extend(&other.forms); + } + /// Place a drawing added with [`Pdf::add_form`], under the current transform and clip. pub fn form(&mut self, id: FormId) { self.forms.insert(id.0); @@ -978,6 +1004,35 @@ mod tests { assert_xref_is_right(&doc.bytes); } + #[test] + fn long_text_wraps_at_spaces_and_content_can_be_joined() { + let words = "one two three four five six seven"; + let width = text_width_pt("one two three", 10.0); + let lines = wrap_text(words, 10.0, width + 0.01); + assert_eq!(lines, ["one two three", "four five six", "seven"]); + assert!(lines.iter().all(|l| text_width_pt(l, 10.0) <= width + 0.01)); + assert_eq!(wrap_text("", 10.0, 100.0), Vec::::new()); + assert_eq!(wrap_text("unbreakable", 10.0, 1.0), ["unbreakable"]); + assert_eq!(wrap_text("fits", 10.0, 1000.0), ["fits"]); + + let mut pdf = Pdf::new("Joined"); + let form = pdf.add_form([0.0, 0.0, 10.0, 10.0], Content::new()); + let mut part = Content::new(); + part.text((10.0, 10.0), 10.0, 0.0, Anchor::Start, 0.0, "part \u{8239}"); + part.form(form); + let mut whole = Content::new(); + whole.polyline(&[(0.0, 0.0), (5.0, 5.0)], false, BLACK); + whole.append(&part); + pdf.add_page(100.0, 100.0, whole); + let doc = pdf.finish().unwrap(); + assert_eq!(page_text(&doc.bytes, 0), ["part ?"]); + assert_eq!(doc.missing, ['\u{8239}']); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!(stream.find("S\n").unwrap() < stream.find("Tj").unwrap()); + assert!(stream.ends_with("/X0 Do\n"), "{stream}"); + assert!(text(&doc.bytes).contains("/XObject << /X0 ")); + } + /// The width is the sum of the font's own advances, read here a second way through `ttf`. #[test] fn anchors_shift_text_by_its_width() { diff --git a/crates/seacraft-core/src/sheet.rs b/crates/seacraft-core/src/sheet.rs new file mode 100644 index 0000000..6b82904 --- /dev/null +++ b/crates/seacraft-core/src/sheet.rs @@ -0,0 +1,1118 @@ +//! A drawing sheet: a 2D drawing in metres, and the writers that put it on paper or send it to a +//! machine. +//! +//! Everything a builder prints goes through [`Sheet`], so the tiled PDF, the single-page PDF, the +//! SVG and the DXF of one pattern are the same lines. x runs right and y up. +//! +//! Line widths and label heights are millimetres on paper: a drawing printed at 1:10 has the same +//! line width as one at 1:1. + +use std::fmt::Write as _; + +use crate::dxf::{write_dxf_in, DxfEntity, DxfLayer, DxfShape, DxfUnits, Vec2}; +pub use crate::pdf::Anchor; +use crate::pdf::{ + text_width_pt, wrap_text, Content, Document, Pdf, Stroke, MAX_PAGE_PT, PT_PER_MM, +}; +use crate::units::scale_label; + +/// What a line on a sheet is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PathKind { + /// Saw here. Drawn 0.35 mm wide, black. + Cut, + /// Transfer to the wood: a centreline, a waterline, a station line. Drawn 0.18 mm wide, black. + Mark, + /// For the eye only, such as the hull surface outside a mould line. Drawn 0.18 mm wide, grey, + /// dashed. + Reference, +} + +/// A line on a sheet, through `points` in metres. +#[derive(Clone, Debug, PartialEq)] +pub struct SheetPath { + /// What the line is for, which decides how it is drawn. + pub kind: PathKind, + /// The points, in order. + pub points: Vec<(f64, f64)>, + /// Whether the last point joins the first. + pub closed: bool, +} + +/// One line of text on a sheet. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// Where its anchor is, in metres. + pub at: (f64, f64), + /// The letter size on paper, in millimetres; the scale does not change it. + pub height_mm: f64, + /// Turned anticlockwise by this much. + pub angle_deg: f64, + /// Which point of the text is at `at`. + pub anchor: Anchor, + /// The words. + pub text: String, +} + +/// A drawing: lines and labels in metres, and notes that go beside it on paper. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + /// What the sheet is, as its pages are captioned: `Station 3`. + pub name: String, + /// The lines. + pub paths: Vec, + /// The text on the drawing. + pub labels: Vec