Merge branch 'lang-3-fonts' into lang-4-window

This commit is contained in:
aiongg committed 2026-10-26 01:55:56 +00:00
commit 1a14a6802f
13 files changed
+249 -47

No files matched your search

Generated
+6 -6
View File
@@ -959,8 +959,8 @@ name = "craft-print"
version = "0.1.0"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree 0.20.0",
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
"winprint",
"winsafe",
@@ -1672,9 +1672,9 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixed_decimal"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd"
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
dependencies = [
"displaydoc",
"smallvec",
@@ -4688,9 +4688,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
+15 -6
View File
@@ -639,12 +639,16 @@ impl FontStore {
entry.files.retain(|f| !removing.contains(&f.name));
let folder = self.dir.join(&entry.dir);
let stage = Stage::new(&self.dir)?;
let moving: Vec<(PathBuf, PathBuf)> = if entry.files.is_empty() {
let mut moving: Vec<(PathBuf, PathBuf)> = removing.iter().map(|f| (folder.join(f), stage.dir.join(f))).collect();
let last = entry.files.is_empty();
if last {
// The licence goes with the last style. Files the store didn't install stay, and
// keep the folder.
if let Some(licence) = entry.license_file.clone().filter(|n| safe_file_name(n) && !removing.contains(n)) {
moving.push((folder.join(&licence), stage.dir.join(&licence)));
}
manifest.families.remove(pos);
vec![(folder, stage.dir.join("removed"))]
} else {
removing.iter().map(|f| (folder.join(f), stage.dir.join(f))).collect()
};
}
let mut moved = Vec::new();
for (from, to) in &moving {
if from.exists() {
@@ -653,7 +657,12 @@ impl FontStore {
}
}
match self.write_manifest(&manifest) {
Ok(()) => Ok(()),
Ok(()) => {
if last {
let _ = std::fs::remove_dir(&folder);
}
Ok(())
}
Err(e) => {
for (from, to) in moved {
let _ = std::fs::rename(to, from);
+13
View File
@@ -694,6 +694,19 @@ mod store {
assert!(dir.join("outside.ttf").exists());
assert!(!dir.join("testsans/TestSans-Bold.ttf").exists());
assert_eq!(store.installed_family(GOOGLE_FONTS, "Test Sans").unwrap().files.len(), 2);
// The last style takes the licence with it; the unlisted file and its folder stay.
let rest: Vec<String> = store.installed_family(GOOGLE_FONTS, "Test Sans").unwrap().files.iter().map(|f| f.name.clone()).collect();
store.remove_files(GOOGLE_FONTS, "Test Sans", &rest).unwrap();
assert!(store.installed_family(GOOGLE_FONTS, "Test Sans").is_none());
let left: Vec<_> = std::fs::read_dir(dir.join("testsans")).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(left, ["Unlisted.ttf"]);
// A folder left empty goes.
std::fs::remove_file(dir.join("testsans/Unlisted.ttf")).unwrap();
store.install_face(&index, index.find_face("Test Sans", "Bold").unwrap(), &Cancel::new(), &mut none).unwrap();
store.remove_files(GOOGLE_FONTS, "Test Sans", &["TestSans-Bold.ttf".into()]).unwrap();
assert!(!dir.join("testsans").exists());
let _ = std::fs::remove_dir_all(dir);
}
+4 -4
View File
@@ -27,11 +27,11 @@ winsafe = { version = "0.0.29", default-features = false, features = ["winspool"
# The job file's private folder.
tempfile = "3"
# Reading the PrintTicket and PrintCapabilities the driver hands back.
roxmltree = "0.20"
roxmltree = "0.21"
# Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs).
miniz_oxide = "0.8"
miniz_oxide = "0.9"
[dev-dependencies]
tempfile = "3"
roxmltree = "0.20"
miniz_oxide = "0.8"
roxmltree = "0.21"
miniz_oxide = "0.9"
+57 -8
View File
@@ -517,6 +517,53 @@ fn indirect<'a>(data: &'a [u8], offset: usize, budget: &mut Budget) -> Option<In
Some(Indirect { num, obj, stream })
}
/// How an inflate ended.
enum Inflated {
/// The whole stream, within the limit.
Done(Vec<u8>),
/// The output would pass the limit.
Exhausted,
/// A damaged or truncated stream: only the bytes actually written before the damage.
Damaged(Vec<u8>),
}
/// Inflate `raw` (zlib-wrapped or bare deflate) to at most `limit` bytes. Unlike the
/// `decompress_to_vec*` helpers it reports the bytes the decompressor really wrote, so a stream
/// rejected early yields an empty `Damaged`, never the zero-filled output buffer.
fn inflate(raw: &[u8], zlib: bool, limit: usize) -> Inflated {
use miniz_oxide::inflate::TINFLStatus;
use miniz_oxide::inflate::core::{DecompressorOxide, decompress, inflate_flags};
let flags = inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | if zlib { inflate_flags::TINFL_FLAG_PARSE_ZLIB_HEADER } else { 0 };
let mut out = vec![0u8; raw.len().saturating_mul(2).max(64).min(limit)];
let mut state = Box::<DecompressorOxide>::default();
let (mut input, mut written) = (raw, 0usize);
loop {
let (status, consumed, wrote) = decompress(&mut state, input, &mut out, written, flags);
written = written.saturating_add(wrote).min(out.len());
match status {
TINFLStatus::Done => {
out.truncate(written);
return Inflated::Done(out);
}
TINFLStatus::HasMoreOutput => {
if out.len() >= limit {
return Inflated::Exhausted;
}
let Some(rest) = input.get(consumed..) else {
out.truncate(written);
return Inflated::Damaged(out);
};
input = rest;
out.resize(out.len().saturating_mul(2).min(limit), 0);
}
_ => {
out.truncate(written);
return Inflated::Damaged(out);
}
}
}
}
/// A stream's data with its filters undone: FlateDecode (with PNG predictors), or none.
fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option<Vec<u8>> {
if budget.exhausted {
@@ -547,20 +594,22 @@ fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option<Vec<u8>> {
Some(raw.to_vec())
}
[b"FlateDecode" | b"Fl"] => {
let inflated = match miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, limit) {
Ok(v) => v,
Err(e) if e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput => {
let inflated = match inflate(raw, true, limit) {
Inflated::Done(v) => v,
Inflated::Exhausted => {
budget.exhausted = true;
return None;
}
// A damaged stream: what inflated before the damage, as other readers do.
Err(e) if !e.output.is_empty() => e.output,
Err(_) => match miniz_oxide::inflate::decompress_to_vec_with_limit(raw, limit) {
Ok(v) => v,
Err(e) => {
budget.exhausted |= e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput;
Inflated::Damaged(v) if !v.is_empty() => v,
// Rejected before any output: retry as a bare deflate stream.
Inflated::Damaged(_) => match inflate(raw, false, limit) {
Inflated::Done(v) => v,
Inflated::Exhausted => {
budget.exhausted = true;
return None;
}
Inflated::Damaged(_) => return None,
},
};
unpredict(&inflated, parms, limit)
+58
View File
@@ -418,3 +418,61 @@ fn large_inherited_resources_are_written_once_for_every_copy() {
assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len());
assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages);
}
fn flate_dict() -> Dict {
vec![(b"Filter".to_vec(), Obj::Name(b"FlateDecode".to_vec()))]
}
fn varied(len: usize) -> Vec<u8> {
(0..len).map(|i| u8::try_from(i * i % 251).unwrap_or(0)).collect()
}
#[test]
fn a_valid_flate_stream_round_trips() {
let raw = varied(4000);
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
let mut budget = Budget::default();
assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw.clone()));
assert_eq!(budget.decoded, raw.len());
assert!(!budget.exhausted);
}
#[test]
fn a_truncated_flate_stream_yields_only_the_bytes_it_inflated() {
let raw = varied(4000);
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
let cut = packed.get(..packed.len() * 3 / 4).unwrap_or_default();
let mut budget = Budget::default();
let got = decode(&flate_dict(), cut, &mut budget).unwrap_or_default();
assert!(!got.is_empty() && got.len() < raw.len());
assert!(raw.starts_with(&got), "a prefix of the data, with no zero padding");
assert!(!budget.exhausted);
}
#[test]
fn a_stream_rejected_before_any_output_is_none_not_zeros() {
// A zlib header, then a reserved deflate block type: nothing is ever inflated, and the
// bare-deflate retry rejects it too.
let bad = [0x78, 0x9c, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff];
let mut budget = Budget::default();
assert!(decode(&flate_dict(), &bad, &mut budget).is_none());
assert_eq!(budget.decoded, 0);
assert!(!budget.exhausted);
}
#[test]
fn a_bare_deflate_stream_still_decodes_through_the_retry() {
let raw = varied(500);
let packed = miniz_oxide::deflate::compress_to_vec(&raw, 6);
let mut budget = Budget::default();
assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw));
}
#[test]
fn an_over_limit_flate_stream_sets_exhausted() {
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&vec![7u8; 4096], 6);
let mut budget = Budget { decoded: MAX_DECODED - 100, ..Budget::default() };
assert!(decode(&flate_dict(), &packed, &mut budget).is_none());
assert!(budget.exhausted);
assert_eq!(budget.decoded, MAX_DECODED - 100);
}
+41 -9
View File
@@ -151,8 +151,17 @@ impl Lang {
let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
let first = *words.first()?;
let code = match locale_language(&words) {
Some("en") => return Some(Lang::English),
Some(code) => <[u8; 2]>::try_from(code.as_bytes()).ok()?,
Some((code, script)) => {
let code = <[u8; 2]>::try_from(code.as_bytes()).ok()?;
// Patterns for one script find nothing in another: `sr-Latn` has none.
if script.is_some_and(|script| script != pattern_script(code)) {
return None;
}
if code == *b"en" {
return Some(Lang::English);
}
code
}
None if first == "english" => return Some(Lang::English),
None => LANGUAGES.iter().find(|(name, _)| *name == first)?.1,
};
@@ -160,10 +169,30 @@ impl Lang {
}
}
/// The language of a locale code split into lowercase `words`: a language (2 or 3 letters), then
/// optionally a script (4 letters), a region (2 letters or 3 digits) and a reform year (4 digits),
/// in that order.
fn locale_language<'a>(words: &[&'a str]) -> Option<&'a str> {
/// The script of hypher's patterns for the language `code`, lowercase, as its documentation
/// lists them.
fn pattern_script(code: [u8; 2]) -> &'static str {
match &code {
b"as" | b"bn" => "beng",
b"be" | b"bg" | b"mn" | b"ru" | b"sr" | b"uk" => "cyrl",
b"hi" | b"mr" | b"sa" => "deva",
b"ka" => "geor",
b"el" => "grek",
b"gu" => "gujr",
b"pa" => "guru",
b"kn" => "knda",
b"ml" => "mlym",
b"or" => "orya",
b"ta" => "taml",
b"te" => "telu",
_ => "latn",
}
}
/// The language and script of a locale code split into lowercase `words`: a language (2 or 3
/// letters), then optionally a script (4 letters), a region (2 letters or 3 digits) and a reform
/// year (4 digits), in that order.
fn locale_language<'a>(words: &[&'a str]) -> Option<(&'a str, Option<&'a str>)> {
fn letters(w: &str, n: std::ops::RangeInclusive<usize>) -> bool {
n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase())
}
@@ -174,17 +203,20 @@ fn locale_language<'a>(words: &[&'a str]) -> Option<&'a str> {
if !letters(lang, 2..=3) {
return None;
}
let script = |w: &str| letters(w, 4..=4);
let script = rest.first().copied().filter(|w| letters(w, 4..=4));
if script.is_some() {
rest = &rest[1..];
}
let region = |w: &str| letters(w, 2..=2) || digits(w, 3);
let year = |w: &str| digits(w, 4);
for subtag in [script as fn(&str) -> bool, region, year] {
for subtag in [region as fn(&str) -> bool, year] {
if let Some((&w, tail)) = rest.split_first()
&& subtag(w)
{
rest = tail;
}
}
rest.is_empty().then_some(lang)
rest.is_empty().then_some((lang, script))
}
/// Where a word's break points came from.
+7 -3
View File
@@ -73,6 +73,7 @@ fn language_names_and_codes() {
for name in ["English", "English: USA", "english", "$ID/English: UK", "en", "en_US", "en-GB", "EN", "en-Latn-US"] {
assert_eq!(Lang::for_language(name), Some(Lang::English), "{name}");
}
assert_eq!(Lang::for_language("en-Cyrl"), None);
// Each language resolves by name and by code exactly when its feature is on.
for &(name, code) in LANGUAGES {
let want = hypher::Lang::from_iso(code).map(|l| Lang::Patterns(PatternLang(l)));
@@ -80,6 +81,7 @@ fn language_names_and_codes() {
let mut title = name.chars();
let title: String = title.next().map(|c| c.to_uppercase().chain(title).collect()).unwrap_or_default();
let upper = code.to_uppercase();
let script = pattern_script(code.as_bytes().try_into().unwrap());
for spelling in [
name.to_string(),
format!("{title}: Reform"),
@@ -88,13 +90,15 @@ fn language_names_and_codes() {
format!("{code}_{upper}"),
format!("{code}-419"),
format!("{code}_{upper}_2006"),
format!("{code}-Latn"),
format!("{code}-Latn-{upper}"),
format!("{code}-{script}"),
format!("{code}-{script}-{upper}"),
format!("{code}-1996"),
format!("{code}-Latn-1996"),
format!("{code}-{script}-1996"),
] {
assert_eq!(Lang::for_language(&spelling), want, "{spelling}");
}
let other = if script == "latn" { "Cyrl" } else { "Latn" };
assert_eq!(Lang::for_language(&format!("{code}-{other}-{upper}")), None, "{code}: patterns of another script");
assert_eq!(Lang::for_language(&format!("{code}_{upper}_x")), None, "{code}: not a locale code");
}
for unknown in ["", "[No Language]", "Klingon", "zh_CN", "Chinese: Simplified", "tlh"] {
+21
View File
@@ -273,6 +273,27 @@ fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
assert!(first_column(&d.layout).iconic);
}
/// A group dropped on the window edge beside an iconic column at that edge docks in a new column
/// there: the preview is the edge line alone, without the column's outline.
#[test]
fn an_edge_drop_next_to_an_iconic_column_highlights_only_the_edge() {
let mut layout = docked(&[&[1], &[2]], true);
layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0]));
let mut d = DockDriver::new(layout, Content);
let bar = hit(&d, Hit::DragBar(5)).center();
let target = pos2(2.0, 300.0);
d.hold(bar, target);
let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0));
assert!(d.filled(Rect::from_x_y_ranges(0.0..=4.0, 0.0..=600.0)), "the edge line");
assert!(!d.outlined(column.shrink(1.0)), "the iconic column is not outlined");
d.release(target);
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) };
let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") };
let panels: Vec<Vec<u32>> = columns.iter().map(|c| c.groups.iter().flat_map(|g| g.panels.clone()).collect()).collect();
assert_eq!(panels, vec![vec![5, 6], vec![1, 2]]);
assert!(!columns[0].iconic && columns[1].iconic);
}
/// Every kind of gesture source has a rect, and dragging from it does something.
#[test]
fn hit_rects_cover_every_gesture_source() {
+7 -3
View File
@@ -789,11 +789,15 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
/// The line of a drop that shows no result rect: on the window edge, into an iconic column,
/// or any dock drop with [`DockStyle::side_edge_zones`].
pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect) {
/// or any dock drop with [`DockStyle::side_edge_zones`]. A line inside an iconic column also
/// outlines the column, unless it is on the window edge (`outer`): a column at that edge holds
/// the line, but the drop docks beside the column, not into it.
pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect, outer: bool) {
if usable(line) {
let painter = self.preview_painter(ctx);
self.iconic.outline(&painter, line, self.style);
if !outer {
self.iconic.outline(&painter, line, self.style);
}
painter.rect_filled(line, 1.0, self.style.accent);
}
}
+3 -3
View File
@@ -599,12 +599,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if self.style.side_edge_zones {
drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled);
} else if matches!(proposal.placement, Placement::Edge(_)) {
self.paint_drop_line(ctx, proposal.preview);
self.paint_drop_line(ctx, proposal.preview, true);
} else if beside {
let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() };
match self.result_rect(action(to), members) {
Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent),
None => self.paint_drop_line(ctx, proposal.preview),
None => self.paint_drop_line(ctx, proposal.preview, false),
}
} else {
tab_outline(painter, proposal.preview.shrink(1.0), self.style.accent);
@@ -621,7 +621,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let rect = (!self.style.side_edge_zones && !drop.outer).then_some(action).and_then(|action| self.result_rect(action, members));
match rect {
Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent),
None => self.paint_drop_line(ctx, drop.line),
None => self.paint_drop_line(ctx, drop.line, drop.outer),
}
}
+15 -4
View File
@@ -91,13 +91,22 @@ impl StartupPrompt {
ui.label(RichText::new(self.app.name).size(15.0).strong().color(text_primary));
ui.label(RichText::new("UPDATE").size(10.0).monospace().color(accent_color));
});
let version_pill = format!("v{} → {}", self.running_version, self.release.version_label());
// ASCII only, like the rest of the card: an app whose fonts lack a glyph may panic
// instead of drawing a box, and this prompt shows up unasked.
let version_pill = format!("v{} to {}", self.running_version, self.release.version_label());
ui.label(RichText::new(version_pill).size(11.0).monospace().color(text_muted));
});
ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
let x_btn = egui::Button::new(RichText::new("✕").size(12.0).color(text_muted)).frame(false);
if ui.add(x_btn).clicked() {
// Two strokes, not a "✕" glyph: none of the bundled faces has U+2715.
let (rect, close) = ui.allocate_exact_size(Vec2::splat(18.0), egui::Sense::click());
close.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close"));
let color = if close.hovered() { text_primary } else { text_muted };
let (center, reach) = (rect.center(), 4.5);
let stroke = egui::Stroke::new(1.5, color);
ui.painter().line_segment([center + Vec2::new(-reach, -reach), center + Vec2::new(reach, reach)], stroke);
ui.painter().line_segment([center + Vec2::new(-reach, reach), center + Vec2::new(reach, -reach)], stroke);
if close.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
response = StartupPromptResponse::Later;
}
});
@@ -304,7 +313,7 @@ mod tests {
for expected in [
"PhotoCraft",
"UPDATE",
"v0.1.0 → 0.2.0",
"v0.1.0 to 0.2.0",
"PhotoCraft 0.2.0 is available with the latest features and bug fixes.",
"What's new",
"DOWNLOAD",
@@ -313,6 +322,8 @@ mod tests {
assert!(painted.contains(expected), "missing {expected:?} in painted text {painted:?}");
}
assert!(!painted.contains("UPDATE AND RESTART"), "download branch must not offer the self-update button: {painted:?}");
// No glyph that a font might lack: apps that panic on a missing glyph would take the prompt down (#343).
assert!(painted.is_ascii(), "the prompt paints a non-ASCII character: {painted:?}");
let fills = rect_fills(&output);
let backdrop = fills.iter().any(|(rect, fill)| *fill == Color32::from_black_alpha(150) && rect.size() == screen().size());
assert!(backdrop, "modal backdrop missing: {fills:?}");
+2 -1
View File
@@ -1307,8 +1307,9 @@ pub mod macos {
Duration::from_secs(300),
)
};
// diskimages-helper can stay busy for a while on a shared runner; a minute of retries is still quick.
let mut attached = attach();
for wait in [1, 2, 4, 8] {
for wait in [1, 2, 4, 8, 16, 32] {
if attached.is_ok() {
break;
}