SAVEAS portableFonts: Windows fonts for Linux text style fonts (#2576)

* SAVEAS portableFonts: Windows fonts for Linux text style fonts

A text style using a Linux TrueType font (Liberation, DejaVu, Open Sans)
shows in AutoCAD on Windows with a substitute it picks. A save now names
those styles on the command line (and as `nonPortableFonts` in the
result), and SAVEAS {"portableFonts": true} writes the Windows equivalent
in the file (Liberation -> Arial, Times New Roman, Courier New; DejaVu
Sans -> Verdana; Open Sans -> Arial) while the drawing keeps its own
fonts. Narrow and condensed families have no Windows stand-in (Arial
Narrow comes with Office) and are kept and named. QSAVE takes no SAVEAS
options.

Tests: stand_in_tests in cmd/file.rs, tests/save_portable_fonts.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts kept in the file, applied by the writer

Reworked after review. SAVEAS `portableFonts` writes a copy of the drawing
with `Drawing::portable_fonts` set, and the DXF/DWG writer turns each
style's Linux font into its Windows font, keeping the style's own font in
the CADCraft FONT xdata already used for the built-in font. Reading the
file gives the Linux fonts back (unless the font was changed since, in
AutoCAD); the drawing itself is never changed.

It applies to that save: a later QSAVE writes the drawing's fonts and
says so, naming the styles and SAVEAS portableFonts. The family table
moves to `cadcraft_fonts::windows`, used by the writer and the note.
Styles from an xref aren't saved and aren't named. The parity docs are
left to the maintainers (CLAUDE.md).

Tests: crates/io/tests/it/portable_fonts.rs (DXF, DWG, a font changed in
AutoCAD), crates/engine/tests/save_portable_fonts.rs, windows.rs tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts: match the Windows font by file name

A STYLE whose Windows font carries a path (C:\Windows\Fonts\…) still
gets its own font back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts: say the font list again only when it changes

A save still returns nonPortableFonts, but the command line note isn't
repeated on every QSAVE of the same drawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrea Ficarra
2026-11-01 14:38:04 -05:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4a9371ad2c
commit c72c0eebfb
10 changed files with 268 additions and 14 deletions
+5
View File
@@ -132,6 +132,10 @@ pub struct Drawing {
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
/// drawing reopens with it. Never set on an open drawing.
pub portable_fonts: bool,
}
impl Default for Drawing {
@@ -191,6 +195,7 @@ impl Drawing {
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
}
}
+52 -7
View File
@@ -85,12 +85,13 @@ pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
/// binary instead of ASCII DXF.
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveAs {
pub version: Option<Version>,
pub binary: bool,
pub portable_fonts: bool,
}
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
@@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
};
Ok(SaveAs { version, binary })
let portable_fonts = match p.get("portableFonts") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
};
Ok(SaveAs { version, binary, portable_fonts })
}
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
@@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
let d = copy.as_ref().unwrap_or(d);
let r = if opts.version.is_none() && !opts.binary {
(hooks.write)(d, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
@@ -178,11 +186,13 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
.menu(&["File", "Save As..."])
.key("Cmd+Shift+S")
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
.params(
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
)
.noundo(),
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
@@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
}
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
save_as(s, "save", path, &opts)
}
@@ -677,6 +687,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
if is_drawing_file(&path) {
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
r["binary"] = json!(opts.binary);
r["portableFonts"] = json!(opts.portable_fonts);
}
Ok(r)
}
@@ -799,9 +810,43 @@ fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> R
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
}
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
report_fonts(s, &mut r);
}
Ok(r)
}
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
/// result, and at the command line when the list changed since the last save. Styles from an xref
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
fn report_fonts(s: &mut Session, r: &mut Value) {
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
let fonts: Vec<(String, String)> = d
.text_styles
.iter()
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
.map(|t| (t.name.clone(), t.font.clone()))
.collect();
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
if fonts.is_empty() {
return;
}
if changed {
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
" SAVEAS with portableFonts writes Windows fonts for those that have one."
} else {
""
};
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
}
if let Some(o) = r.as_object_mut() {
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
}
}
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_save)
}
+4
View File
@@ -35,6 +35,9 @@ pub struct DocState {
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
/// drawing itself waits in it ([`DocState::drawing`]).
pub block_edit: Option<blockedit::BlockEdit>,
/// The fonts missing on Windows that a save last named: the next save names them again only
/// when they change.
pub(crate) fonts_noted: String,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -61,6 +64,7 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
binary_dxf: false,
block_edit: None,
fonts_noted: String::new(),
}
}
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
@@ -0,0 +1,54 @@
//! Text styles with Linux fonts AutoCAD on Windows doesn't have: a save names them, and SAVEAS
//! with portableFonts writes Windows fonts in that file while the drawing keeps its own.
use cadcraft_doc::{Drawing, TextStyle};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use serde_json::json;
fn dxf(d: &Drawing, _: &str) -> Result<Vec<u8>, String> {
Ok(cadcraft_io::write_dxf(d).into_bytes())
}
fn session() -> Session {
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: dxf, plot: None });
let mut d = Drawing::new_metric();
for (name, font) in [("Notes", "DejaVuSans.ttf"), ("Plain", "arial.ttf"), ("Tight", "Liberation Sans Narrow")] {
d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() });
}
let mut s = Session::empty();
s.open_drawing(d, "fonts.dxf", None);
s
}
#[test]
fn portable_fonts_writes_windows_fonts_in_that_file() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("cadcraft-portable-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
let file = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
let both = json!([{ "style": "Notes", "font": "DejaVuSans.ttf" }, { "style": "Tight", "font": "Liberation Sans Narrow" }]);
// portableFonts writes Verdana for DejaVu Sans; the narrow style has no Windows font.
let r = s.execute("saveas", &json!({ "path": path("portable.dxf"), "portableFonts": true })).unwrap();
assert_eq!(r["nonPortableFonts"], json!([{ "style": "Tight", "font": "Liberation Sans Narrow" }]));
assert_eq!(s.log.last().map(String::as_str), Some("Fonts AutoCAD on Windows doesn't have: Tight (Liberation Sans Narrow)."));
assert!(file("portable.dxf").contains("verdana.ttf"));
assert!(s.state().unwrap().drawing().text_styles.iter().any(|t| t.font == "DejaVuSans.ttf"), "the drawing keeps its fonts");
// A later save without it writes the drawing's fonts again, and says so.
let r = s.execute("qsave", &json!({})).unwrap();
assert_eq!(r["nonPortableFonts"], both);
assert!(s.log.last().is_some_and(|l| l.ends_with("SAVEAS with portableFonts writes Windows fonts for those that have one.")), "{:?}", s.log);
assert!(!file("portable.dxf").contains("verdana.ttf"));
// The same list isn't said again on every save.
let lines = s.log.len();
let r = s.execute("qsave", &json!({})).unwrap();
assert_eq!(r["nonPortableFonts"], both);
assert!(s.log.iter().skip(lines).all(|l| !l.starts_with("Fonts AutoCAD")), "{:?}", s.log);
assert!(s.execute("saveas", &json!({ "path": path("bad.dxf"), "portableFonts": "yes" })).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -21,6 +21,7 @@ mod mtext;
mod stroke;
pub mod subset;
pub mod ttf;
pub mod windows;
use cadcraft_geom::{Bounds2, Vec2};
+82
View File
@@ -0,0 +1,82 @@
//! Linux font families that AutoCAD on Windows doesn't have, and the Windows fonts a drawing
//! saved with portable fonts gets instead.
/// Family key (lower case, without spaces, hyphens or underscores; a longer key before a shorter
/// one it starts with) and the Windows fonts for its regular, bold, italic and bold italic styles.
const FAMILIES: [(&str, [&str; 4]); 7] = [
("liberationsans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]),
("liberationserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]),
("liberationmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]),
("opensans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]),
("dejavusansmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]),
("dejavusans", ["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"]),
("dejavuserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]),
];
/// The family of a TrueType `font` (file or family name) from [`FAMILIES`], with the rest of its
/// key (the style: `bold`, `italic`, …).
fn family(font: &str) -> Option<(&'static [&'static str; 4], String)> {
let name = font.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_lowercase();
let stem = name.strip_suffix(".ttf").or_else(|| name.strip_suffix(".otf")).unwrap_or(&name);
if stem.contains('.') {
return None; // another kind of file, such as an SHX font
}
let key: String = stem.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).collect();
let (family, fonts) = FAMILIES.iter().find(|(f, _)| key.starts_with(f))?;
Some((fonts, key.get(family.len()..).unwrap_or_default().to_string()))
}
/// Whether `font` is a Linux font AutoCAD on Windows doesn't have.
pub fn missing_on_windows(font: &str) -> bool {
family(font).is_some()
}
/// The Windows font for a Linux `font`. Narrow and condensed styles have none (Arial Narrow comes
/// with Office, not with Windows).
pub fn windows_font(font: &str) -> Option<&'static str> {
let (fonts, style) = family(font)?;
if style.contains("narrow") || style.contains("condensed") {
return None;
}
let bold = style.contains("bold");
let italic = style.contains("italic") || style.contains("oblique");
fonts.get(usize::from(bold) + 2 * usize::from(italic)).copied()
}
/// The font a drawing file gets for a text style's `font`: with `portable`, the Windows font for
/// a Linux one.
pub fn written_font(font: &str, portable: bool) -> &str {
if portable { windows_font(font).unwrap_or(font) } else { font }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_fonts_by_file_or_family_name() {
assert_eq!(windows_font("DejaVuSans.ttf"), Some("verdana.ttf"));
assert_eq!(windows_font("/usr/share/fonts/truetype/LiberationSerif-Bold.ttf"), Some("timesbd.ttf"));
assert_eq!(windows_font("Liberation Mono"), Some("cour.ttf"));
assert_eq!(windows_font("LiberationMono-BoldItalic.ttf"), Some("courbi.ttf"));
assert_eq!(windows_font("OpenSans-SemiBold.ttf"), Some("arialbd.ttf"));
assert_eq!(windows_font("Open Sans"), Some("arial.ttf"));
assert_eq!(windows_font("DejaVuSansMono-BoldOblique.ttf"), Some("courbi.ttf"));
assert_eq!(windows_font("DejaVuSerif-Italic.ttf"), Some("timesi.ttf"));
// Narrow and condensed styles are Linux fonts with no Windows one.
for font in ["Liberation Sans Narrow", "LiberationSansNarrow-Bold.ttf", "OpenSans-CondensedBold.ttf", "DejaVuSansCondensed.ttf"] {
assert!(missing_on_windows(font) && windows_font(font).is_none(), "{font}");
}
// Fonts AutoCAD has, other kinds of file, or names that aren't fonts.
for font in ["arial.ttf", "txt", "dejavusans.shx", "", "/", "Liberation"] {
assert!(!missing_on_windows(font) && windows_font(font).is_none(), "{font}");
}
}
#[test]
fn written_font_only_changes_with_portable_fonts() {
assert_eq!(written_font("DejaVuSans.ttf", true), "verdana.ttf");
assert_eq!(written_font("DejaVuSans.ttf", false), "DejaVuSans.ttf");
assert_eq!(written_font("Liberation Sans Narrow", true), "Liberation Sans Narrow");
}
}
+6 -4
View File
@@ -4,6 +4,7 @@ use super::{
Color, DashElement, DimStyle, Drawing, HVal, HashMap, Layer, Linetype, Lineweight, NamedView, Rgb, Rx, Tag, TextStyle, Ucs, Vec2, Vec3,
VportConfig, VportTile, records,
};
use cadcraft_fonts::windows::windows_font;
pub(super) fn header(tags: &[Tag], d: &mut Drawing) {
let mut i = 0;
@@ -66,9 +67,9 @@ fn annotative(tags: &[Tag]) -> bool {
}
/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name)
/// while the font file is still the `txt` written for other readers; the TrueType family of the
/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType
/// styles); otherwise the file.
/// while the font file is still the `txt` or Windows font written for other readers; the TrueType
/// family of the style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD
/// stores TrueType styles); otherwise the file.
fn style_font(tags: &[Tag], file: String) -> String {
if file.trim().is_empty()
&& let Some(face) =
@@ -82,8 +83,9 @@ fn style_font(tags: &[Tag], file: String) -> String {
_ => None,
});
let stem = file.trim().to_ascii_lowercase();
let base = stem.rsplit(['/', '\\']).next().unwrap_or_default();
match own {
Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name,
Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx" || windows_font(&name) == Some(base)) => name,
_ => file,
}
}
+4 -3
View File
@@ -90,9 +90,10 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap<String, String>
w.f(50, s.oblique.to_degrees());
w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 });
w.f(42, 0.2);
// The built-in font goes out as `txt`, which every reader has; CADCraft's own name
// travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it.
let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() };
// The built-in font goes out as `txt`, which every reader has, and a Linux font as its
// Windows one with portable fonts; CADCraft's own name travels in `CADCRAFT` xdata
// (`1000 FONT`, name) so the style reopens with it.
let file = if s.font == cadcraft_fonts_name() { "txt" } else { cadcraft_fonts::windows::written_font(&s.font, d.portable_fonts) };
w.s(3, file);
w.s(4, &s.big_font);
if s.annotative {
+1
View File
@@ -54,6 +54,7 @@ mod pdf_read;
mod plot_layout_scale;
mod plot_model_png_scale;
mod plot_styles;
mod portable_fonts;
mod region_roundtrip;
mod roundtrip;
mod roundtrip_audit;
+59
View File
@@ -0,0 +1,59 @@
//! Portable fonts: a drawing saved with them gets the Windows font for each Linux font in its
//! STYLE table, keeps its own in CADCraft xdata, and reopens with it, from DXF and DWG.
use cadcraft_doc::{Drawing, TextStyle};
use cadcraft_io::{read, read_dxf, write, write_dxf};
fn drawing(portable_fonts: bool) -> Drawing {
let mut d = Drawing { portable_fonts, ..Drawing::new_metric() };
// A Linux font, and one with no Windows font.
for (name, font) in [("Notes", "DejaVuSans-Bold.ttf"), ("Tight", "Liberation Sans Narrow")] {
d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() });
}
d
}
/// (style, font) of the drawing's text styles.
fn fonts(d: &Drawing) -> Vec<(String, String)> {
d.text_styles.iter().map(|t| (t.name.clone(), t.font.clone())).collect()
}
/// (style, font file) of the STYLE records of DXF text.
fn style_files(dxf: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let mut style = false;
for t in cadcraft_dxf::parse(dxf.as_bytes()).unwrap() {
match t.code {
0 => style = t.str() == "STYLE",
2 if style => out.push((t.str(), String::new())),
3 if style => out.last_mut().unwrap().1 = t.str(),
_ => {}
}
}
out
}
#[test]
fn styles_get_windows_fonts_and_reopen_with_their_own() {
let d = drawing(true);
let dxf = write_dxf(&d);
let files = style_files(&dxf);
for (style, file) in [("Notes", "verdanab.ttf"), ("Tight", "Liberation Sans Narrow")] {
assert!(files.contains(&(style.into(), file.into())), "{style}: {files:?}");
}
for back in [read_dxf(dxf.as_bytes()).unwrap(), read(&write(&d, "t.dwg").unwrap(), "t.dwg").unwrap()] {
assert_eq!(fonts(&back), fonts(&drawing(false)));
}
// The Windows font with a path is still the one written for the style's own.
let path = read_dxf(dxf.replace("verdanab.ttf", "C:\\Windows\\Fonts\\verdanab.ttf").as_bytes()).unwrap();
assert_eq!(fonts(&path), fonts(&drawing(false)));
// A font changed in AutoCAD since (the xdata left as it was) is the font read.
let edited = read_dxf(dxf.replace("verdanab.ttf", "times.ttf").as_bytes()).unwrap();
assert!(fonts(&edited).contains(&("Notes".into(), "times.ttf".into())));
}
#[test]
fn without_portable_fonts_the_file_has_the_drawings_fonts() {
let files = style_files(&write_dxf(&drawing(false)));
assert!(files.contains(&("Notes".into(), "DejaVuSans-Bold.ttf".into())), "{files:?}");
}