Merge origin/main

This commit is contained in:
Federico Olivares
2026-10-24 14:34:57 -03:00
80 changed files with 1335 additions and 524 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -420,7 +420,7 @@ fn trigger_save_file_picker(
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
+1 -1
View File
@@ -207,7 +207,7 @@ fn on_path(program: &str) -> bool {
fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.and_then(std::ffi::OsStr::to_str)
.map(|e| e.to_ascii_lowercase())
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
+2 -2
View File
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
@@ -46,7 +46,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
+1 -1
View File
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
while it.next_if(char::is_ascii_alphabetic).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
+26 -24
View File
@@ -229,7 +229,7 @@ impl Path {
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
write!(f, "{}", s.join("/"))
}
}
@@ -801,34 +801,36 @@ impl Document {
d
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
fn fix(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
fix_cell(&mut c.blocks);
}
fn fix_cell_blocks(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fn fix_story_blocks(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
Self::fix_cell_blocks(&mut c.blocks);
}
}
}
}
}
fn fix_cell(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fix(&mut self.body);
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
Self::fix_story_blocks(&mut self.body);
for p in self.parts.values_mut() {
if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new()));
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
i += 1;
}
if i == start {
+3 -3
View File
@@ -339,7 +339,7 @@ impl P {
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√' | '∛' | '∜' => {
'√'..='∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
@@ -410,7 +410,7 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
end += 1;
}
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
@@ -628,7 +628,7 @@ impl P {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
while self.peek().is_some_and(char::is_alphanumeric) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+7
View File
@@ -311,7 +311,14 @@ pub enum ShapeKind {
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
+3
View File
@@ -342,6 +342,9 @@ impl Highlight {
}
}
/// The text size, in points, of a run whose size no style or default sets.
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
/// Character formatting (direct or in a style). `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
+2 -2
View File
@@ -78,7 +78,7 @@ impl ResolvedChar {
ResolvedChar {
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
bold: c.bold.unwrap_or(false),
italic: c.italic.unwrap_or(false),
underline: c.underline.unwrap_or_default(),
@@ -113,7 +113,7 @@ impl ResolvedChar {
cs: c.cs.unwrap_or(false),
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
font_ea: c.font_ea.clone(),
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
}
+34 -5
View File
@@ -92,11 +92,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
vec![(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)]
}
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
ShapeKind::Star => (0..10)
.map(|i| {
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
@@ -126,6 +122,26 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
Some(pts)
}
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
/// thickness long, the shaft 40% of the thickness thick.
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
let (len, thick) = if vertical { (h, w) } else { (w, h) };
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
right
.iter()
.map(|&(u, v)| match kind {
ShapeKind::ArrowLeft => (1.0 - u, v),
ShapeKind::ArrowUp => (v, 1.0 - u),
ShapeKind::ArrowDown => (v, u),
_ => (u, v),
})
.collect()
}
/// Largest side of the grid [`alpha_outline`] looks at.
pub const ALPHA_GRID: usize = 48;
@@ -214,6 +230,16 @@ mod tests {
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
}
/// #3919: the tip of each block arrow lies on the side it points to.
#[test]
fn arrow_outlines_point_the_right_way() {
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
}
#[test]
fn shape_outlines_stay_in_the_frame() {
for kind in [
@@ -223,6 +249,9 @@ mod tests {
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Star,
ShapeKind::Heart,
] {
+4 -1
View File
@@ -190,7 +190,10 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
5 => ShapeKind::Triangle,
6 => ShapeKind::RightTriangle,
12 | 187 => ShapeKind::Star,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
13 | 69 => ShapeKind::Arrow,
66 => ShapeKind::ArrowLeft,
67 => ShapeKind::ArrowDown,
68 => ShapeKind::ArrowUp,
20 | 32 => ShapeKind::Line,
74 => ShapeKind::Heart,
202 => ShapeKind::TextBox,
+1 -1
View File
@@ -70,7 +70,7 @@ fn fbse_png(png: &[u8]) -> Vec<u8> {
/// UTF-16LE, NUL-terminated (a complex string property).
fn wz(s: &str) -> Vec<u8> {
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
}
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
+5 -16
View File
@@ -231,22 +231,11 @@ impl PropCtx {
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
c.font =
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|| {
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
// Latin font only when the run is hinted as that script. Otherwise the Latin
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, as in Word).
match k.attr("w:hint") {
Some("eastAsia") => k.attr("w:eastAsia"),
Some("cs") => k.attr("w:cs"),
_ => None,
}
.filter(|f| !f.is_empty())
.map(str::to_string)
},
);
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
// Latin letters. A Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, and so does a hinted run, as in Word.
c.font = themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi"));
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
// left to inherit: the theme's per-script fonts aren't read.
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
+14 -4
View File
@@ -41,9 +41,10 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"rightArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => ShapeKind::Arrow,
"leftArrow" => ShapeKind::ArrowLeft,
"upArrow" => ShapeKind::ArrowUp,
"downArrow" => ShapeKind::ArrowDown,
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
@@ -469,6 +470,15 @@ mod tests {
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
/// #3919: Word's up, down and left block arrows were all read as the right-pointing arrow.
#[test]
fn block_arrows_keep_their_direction() {
assert_eq!(preset_kind("rightArrow"), ShapeKind::Arrow);
assert_eq!(preset_kind("leftArrow"), ShapeKind::ArrowLeft);
assert_eq!(preset_kind("upArrow"), ShapeKind::ArrowUp);
assert_eq!(preset_kind("downArrow"), ShapeKind::ArrowDown);
}
#[test]
fn vml_presets_map_like_drawingml_presets() {
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
@@ -477,7 +487,7 @@ mod tests {
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
// star, rounded callout, elbow connector, a bevel (its box).
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(66), Some(ShapeKind::ArrowLeft));
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
+45 -2
View File
@@ -1751,7 +1751,7 @@ impl Reader<'_> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.map(|f| css_string(f).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty());
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
let warp = vml_spt(shape, shapetype, type_id)
@@ -2231,6 +2231,49 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
}
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
/// lone trailing `\` none of CSS would accept stays literal.
fn css_string(v: &str) -> String {
let v = v.trim();
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
let mut out = String::with_capacity(v.len());
let mut chars = v.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 {
match chars.clone().next() {
Some(h) if h.is_ascii_hexdigit() => {
hex.push(h);
chars.next();
}
_ => break,
}
}
if hex.is_empty() {
match chars.next() {
Some(e) => out.push(e),
None => out.push('\\'),
}
} else {
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
out.push(d);
}
// One whitespace after the escape is its terminator, not part of the value.
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
chars.next();
}
}
}
out
}
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
@@ -2332,7 +2375,7 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.map(|f| css_string(f).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty())
.unwrap_or(default.font);
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
if after_sign.first().is_some_and(u8::is_ascii_digit) {
in_exp = true;
continue;
}
+9 -1
View File
@@ -720,7 +720,15 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
w.open("w:docDefaults", &[]);
w.open("w:rPrDefault", &[]);
w.open("w:rPr", &[]);
props::rpr_inner(&mut w, &s.default_chr);
if s.default_chr.size.is_none() {
// The reader reads an omitted `w:sz` as 10 pt, but the resolver falls back to
// DEFAULT_FONT_SIZE, so writing nothing would change the size on reopening.
let mut chr = s.default_chr.clone();
chr.size = Some(wordcraft_doc::props::DEFAULT_FONT_SIZE);
props::rpr_inner(&mut w, &chr);
} else {
props::rpr_inner(&mut w, &s.default_chr);
}
w.close("w:rPr");
w.close("w:rPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
+61 -42
View File
@@ -29,6 +29,43 @@ enum Piece<'a> {
}
impl Writer<'_> {
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
}
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
*toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
}
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
/// Write a block list. `top` = body level (sections allowed).
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
if depth > MAX_DEPTH {
@@ -43,14 +80,7 @@ impl Writer<'_> {
for c in plan.opens.get(i).into_iter().flatten() {
super::sdt::open(w, c);
}
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
self.update_toc_before_block(i, toc, toc_field_open, w);
match &**b {
Block::Para(p) => {
self.inline_controls = plan.inline_in(i);
@@ -58,16 +88,7 @@ impl Writer<'_> {
}
Block::Table(t) => self.table(w, t, rels, depth),
}
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
super::sdt::close(w);
}
@@ -76,16 +97,7 @@ impl Writer<'_> {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
self.write_trailing_para(w, depth);
}
}
@@ -158,6 +170,23 @@ impl Writer<'_> {
self.para_inner(w, p, rels, top, depth, false);
}
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(*idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, mark);
if let Some(ch) = mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
let para_id = self.take_para_id(p);
match &para_id {
@@ -185,20 +214,7 @@ impl Writer<'_> {
});
ppr_inner(w, &pp, framed, num_change.as_ref());
if has_mark {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, &p.mark);
if let Some(ch) = p.mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
self.write_mark_rpr(w, &p.mark, &mark_revs);
}
if let Some(s) = section {
self.sectpr(w, s, rels);
@@ -1053,6 +1069,9 @@ impl Writer<'_> {
ShapeKind::ElbowConnector => "bentConnector3",
ShapeKind::CurvedConnector => "curvedConnector3",
ShapeKind::Arrow => "rightArrow",
ShapeKind::ArrowLeft => "leftArrow",
ShapeKind::ArrowUp => "upArrow",
ShapeKind::ArrowDown => "downArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
+15 -2
View File
@@ -23,8 +23,21 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
let text = text(wm)?;
let [width, height] = wm.frame(page.page_w, page.page_h);
// A font name can't break out of the textpath's CSS-like style.
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
// double-quoted string, so it is kept.
let mut font = String::with_capacity(wm.font.len());
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
match c {
'"' => font.push_str(r"\22 "),
';' => font.push_str(r"\3b "),
':' => font.push_str(r"\3a "),
'\\' => font.push_str(r"\5c "),
c => font.push(c),
}
}
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
let style = format!(
+69
View File
@@ -0,0 +1,69 @@
//! The document default font size (`w:docDefaults/w:rPrDefault/w:rPr/w:sz`) across a save and
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
//! the reader reads an omitted size as 10 pt.
use std::io::{Read, Write};
use wordcraft_doc::Document;
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
fn resolved(d: &Document) -> f32 {
d.styles.resolve_char(None, &CharProps::default()).size
}
fn styles_xml(bytes: &[u8]) -> String {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
archive.by_name("word/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
fn defaults(xml: &str) -> &str {
xml.split("<w:docDefaults>").nth(1).unwrap().split("</w:docDefaults>").next().unwrap()
}
#[test]
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
let mut d = Document::new();
d.styles.default_chr.size = None;
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
let reopened = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(resolved(&reopened), DEFAULT_FONT_SIZE);
// Saving again changes nothing further.
let again = wordcraft_docx::read(&wordcraft_docx::write(&reopened).unwrap()).unwrap();
assert_eq!(resolved(&again), DEFAULT_FONT_SIZE);
}
#[test]
fn an_explicit_default_size_is_written_as_it_is() {
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
let mut d = Document::new();
d.styles.default_chr.size = Some(size);
let bytes = wordcraft_docx::write(&d).unwrap();
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
}
}
#[test]
fn a_file_that_omits_the_default_size_is_still_read_as_ten_points() {
// The reader is unchanged: only what WordCraft writes differs. A package without a size
// anywhere keeps its established reading.
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>"#;
let styles = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault></w:docDefaults></w:styles>"#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in [("_rels/.rels", root), ("word/document.xml", doc), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
let d = wordcraft_docx::read(&zw.finish().unwrap().into_inner()).unwrap();
assert_eq!(d.styles.default_chr.size, Some(10.0));
}
#[test]
fn an_unset_default_size_does_not_take_a_styles_size() {
// The written default is the resolver's own fallback, not what the Normal style resolves to,
// so a style that sets a size keeps it and one that does not still falls back to the default.
let mut d = Document::new();
d.styles.default_chr.size = None;
if let Some(normal) = d.styles.get_mut("Normal") {
normal.chr.size = Some(14.0);
}
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(reopened.styles.default_chr.size, Some(DEFAULT_FONT_SIZE));
assert_eq!(resolved(&reopened), resolved(&d));
}
+26 -7
View File
@@ -1040,7 +1040,7 @@ fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
#[test]
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
// docDefaults font. A run hinted as East Asian keeps its East Asian font in `font_ea` (#4031).
let styles = format!(
r#"<w:styles {W_NS}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
@@ -1051,9 +1051,28 @@ fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
let ps = paras(&d);
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
assert_eq!(font(ps[0]), "Calibri");
assert_eq!(font(ps[1]), "SimSun");
let rc = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!(rc(ps[0]).font, "Calibri");
assert_eq!((rc(ps[1]).font.as_str(), rc(ps[1]).font_ea.as_deref()), ("Calibri", Some("SimSun")));
}
#[test]
fn hinted_east_asian_or_cs_font_does_not_replace_the_latin_font() {
// Issue #4031: `w:hint` only picks the font for ambiguous characters, so a run with
// `w:eastAsia` + `w:hint="eastAsia"` and no `w:ascii` keeps the style's Latin font for its
// Latin text; the East Asian / complex-script fonts stay in their own fields.
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman" w:eastAsiaTheme="majorEastAsia"/><w:b/></w:rPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:rPr><w:rFonts w:eastAsia="宋体" w:hint="eastAsia"/></w:rPr><w:t>corn straw</w:t></w:r><w:r><w:rPr><w:rFonts w:cs="Arial" w:hint="cs"/></w:rPr><w:t>model fluids</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let p = paras(&d)[0];
let ea = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((ea.font.as_str(), ea.font_ea.as_deref()), ("Times New Roman", Some("宋体")));
let cs = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[1].props);
assert_eq!((cs.font.as_str(), cs.font_cs.as_str()), ("Times New Roman", "Arial"));
}
#[test]
@@ -2387,7 +2406,7 @@ fn vml_preset_shapes_are_drawn() {
assert_eq!(kind(15), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(16), Some((ShapeKind::Diamond, false)));
assert_eq!(kind(17), Some((ShapeKind::Ellipse, false)));
assert_eq!(shape(18), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(shape(18), Some((ShapeKind::ArrowLeft, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(kind(19), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(20), Some((ShapeKind::Star, false)));
assert_eq!(shape(21), None);
@@ -2458,12 +2477,12 @@ fn vml_nearest_preset_shape_survives_saving() {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
assert!(xml.contains(r#"prst="leftArrow""#), "{xml}");
let again = wordcraft_docx::read(&saved).unwrap();
assert!(
matches!(
&paras(&again)[0].objects[..],
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
[InlineObject::Shape { kind: ShapeKind::ArrowLeft, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
),
"{:?}",
paras(&again)[0].objects
+1
View File
@@ -7,6 +7,7 @@ mod complex_script_inheritance;
mod content_control_save_cost;
mod content_controls;
mod custom_xml;
mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
+27
View File
@@ -1545,6 +1545,33 @@ fn watermark_roundtrips() {
}
}
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
/// the style's closing quote.
#[test]
fn watermark_font_roundtrips_through_the_textpath_style() {
for font in ["Georgia", "Bob's Serif", "A;Named:Font", "Quote\"Inside", "Ends with \\", "All \"of\" ;it: 'here\\", "'Serif'", "Trailing'"] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: font.into(), ..Default::default() });
assert_eq!(rt(&d).settings.watermark.map(|w| w.font), Some(font.into()), "{font:?}");
}
}
/// #3994: the written textpath style stays well-formed CSS whatever the font name: control
/// characters are dropped (no font name carries one) and a backslash or quote is a CSS escape
/// (`\5c `, `\22 `) instead of escaping the string's closing quote.
#[test]
fn watermark_font_name_writes_wellformed_css() {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: "Ends with \\ and \n\"quoted\"".into(), ..Default::default() });
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
let style = hdr.split("v:textpath style=\"").nth(1).and_then(|s| s.split('"').next()).unwrap_or_default();
assert!(!style.contains('\n'), "control characters are dropped: {style}");
assert!(style.contains(r#"font-family:&quot;Ends with \5c and \22 quoted\22 &quot;;"#), "CSS escapes: {style}");
}
/// #1290: a document without headers gains one default header holding only the watermark (an
/// empty paragraph once read back); saving again neither adds another nor duplicates the shape.
#[test]
+1 -1
View File
@@ -1090,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
out.push((l, false));
}
}
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
let list = pages.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
}
out
+29 -21
View File
@@ -579,27 +579,8 @@ fn info(s: &mut Session, v: &Value) -> CmdResult {
Ok(describe(s, &r))
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
let mut content = None;
match &mut c.kind {
ControlKind::Text { multi_line } => {
if let Some(m) = p::bool(v, "multiLine") {
@@ -633,6 +614,33 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
}
_ => {}
}
content
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
if let Some(kc) = apply_kind_properties(&mut c, v) {
content = Some(kc);
}
if let Some(text) = content {
let base = content_base(s, &r);
let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base };
+1 -1
View File
@@ -246,7 +246,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
+60 -50
View File
@@ -601,6 +601,42 @@ fn convert(src: &str, mode: &str, para_start: bool) -> String {
convert_chars(src, mode, para_start, &turkic).concat()
}
fn convert_title_char(c: char, i: usize, up: &mut bool, upper: impl Fn(char) -> String, lower: impl Fn(usize, char) -> String) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
if c.is_whitespace() {
*up = true;
}
n
}
fn convert_sentence_char(
c: char,
i: usize,
up: &mut bool,
chars: &[char],
upper: impl Fn(char) -> String,
lower: impl Fn(usize, char) -> String,
) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(char::is_ascii_digit)
&& chars.get(i + 1).is_some_and(char::is_ascii_digit);
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
*up = true;
}
n
}
/// The case-converted form of each character of `src`, in order (one entry per char, so a
/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
/// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
@@ -634,34 +670,8 @@ fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Ve
upper(c)
}
}
"title" => {
let n = if up && c.is_alphabetic() {
up = false;
upper(c)
} else {
lower(i, c)
};
if c.is_whitespace() {
up = true;
}
n
}
_ => {
// Sentence case.
let n = if up && c.is_alphabetic() {
up = false;
upper(c)
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
up = true;
}
n
}
"title" => convert_title_char(c, i, &mut up, upper, lower),
_ => convert_sentence_char(c, i, &mut up, &chars, upper, lower),
};
out.push(new);
}
@@ -756,28 +766,6 @@ pub fn state(s: &Session) -> Value {
})
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
/// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)]
@@ -884,3 +872,25 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
s.sel = crate::Selection { anchor: at, focus: end };
sel_result(s)
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
+67 -67
View File
@@ -979,6 +979,73 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1308,70 +1375,3 @@ mod tests {
assert_eq!(date_picture("DATE"), None);
}
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
+42 -5
View File
@@ -524,7 +524,16 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
let first = text.lines().next().unwrap_or("");
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| first.matches(*c).count()).unwrap_or(',');
let mut quoted = false;
let mut unquoted = String::new();
for c in first.chars() {
if c == '"' {
quoted = !quoted;
} else if !quoted {
unquoted.push(c);
}
}
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
@@ -837,7 +846,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '=' | '<' | '>')).collect();
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or("");
let v = value(s, row, &field);
@@ -1180,13 +1189,24 @@ fn envelope_size(v: &Value) -> Result<(f32, f32), CmdError> {
.ok_or_else(|| CmdError::Params(format!("unknown envelope size `{name}`")))
}
/// Longest label or envelope address text accepted.
const MAX_LABEL_TEXT: usize = 2_000;
/// An optional label/envelope text parameter, rejected when longer than `MAX_LABEL_TEXT` characters.
fn label_text(v: &Value, key: &str) -> Result<Option<String>, CmdError> {
match p::str(v, key) {
Some(t) if t.chars().count() > MAX_LABEL_TEXT => Err(CmdError::Params(format!("`{key}` is longer than {MAX_LABEL_TEXT} characters"))),
t => Ok(t.map(str::to_string)),
}
}
/// Envelopes: a landscape page with the return address at the top left and the delivery address
/// in the middle. As a new document, or (`add`) as a section of its own at the start of this one.
fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| {
let delivery = label_text(v, "delivery")?.unwrap_or_else(|| {
if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) }
});
let ret = p::str(v, "return").unwrap_or("").to_string();
let ret = label_text(v, "return")?.unwrap_or_default();
let (w, h) = envelope_size(v)?;
let sect = SectionProps {
page_w: w,
@@ -1408,7 +1428,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
(at("row").min(rows), at("col").min(cols))
});
let from_rec = single.is_none() && p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none());
let text = p::str(v, "text").unwrap_or("Name\nAddress\nCity, ST 00000").to_string();
let text = label_text(v, "text")?.unwrap_or_else(|| "Name\nAddress\nCity, ST 00000".into());
let (page_w, page_h) = if prod.paper == "A4" { (595.3, 841.9) } else { (612.0, 792.0) };
let h_gap = (prod.h_pitch - prod.width).max(0.0);
let v_gap = (prod.v_pitch - prod.height).max(0.0);
@@ -1481,6 +1501,12 @@ mod tests {
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
}
#[test]
fn csv_delimiter_ignores_quoted_text() {
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
#[test]
fn merge_end_to_end() {
let mut s = Session::new(Document::new());
@@ -1723,6 +1749,17 @@ mod tests {
assert!(!s.doc.parts.contains_key(&hdr), "the template header is replaced by the letters' copies");
}
#[test]
fn label_and_envelope_text_is_capped() {
let mut s = Session::new(Document::new());
let long = "x".repeat(2001);
assert!(matches!(s.run("mailings.labels", &json!({"text": long})), Err(CmdError::Params(_))));
assert!(matches!(s.run("mailings.envelopes", &json!({"delivery": long})), Err(CmdError::Params(_))));
assert!(matches!(s.run("mailings.envelopes", &json!({"return": long})), Err(CmdError::Params(_))));
s.run("mailings.labels", &json!({"text": "x".repeat(2000), "rows": 40, "cols": 10})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).chars().count() <= 400 * 2000 + 400 * 20);
}
#[test]
fn envelopes_and_labels() {
let mut s = Session::new(Document::new());
+26 -4
View File
@@ -3,7 +3,7 @@
//! Line Only, Show Text Formatting. What's collapsed and shown is view state ([`OutlineState`]),
//! never saved in the document; level changes and moves are ordinary undoable edits.
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::hash::{Hash, Hasher};
use serde::{Deserialize, Serialize};
@@ -464,10 +464,20 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
/// the nearest place it does, in the direction it was going.
pub(crate) fn after_command(s: &mut Session, before: &Selection) {
if !s.view.outline.collapsed.is_empty() {
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
if changed && !s.view.outline.collapsed.is_empty() {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
@@ -476,10 +486,22 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection) {
let found = if free(i, &used) && same(i) {
Some(i)
} else {
(0..levels.len()).filter(|j| free(*j, &used) && same(*j)).min_by_key(|j| j.abs_diff(i)).or_else(|| free(i, &used).then_some(i))
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
+49 -37
View File
@@ -228,6 +228,50 @@ enum Tok {
Cmp(&'static str),
}
fn parse_num_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<Tok, CmdError> {
let mut lit = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
lit.push(d);
chars.next();
} else {
break;
}
}
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
Ok(Tok::Num(x))
}
fn parse_name_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
let mut name = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_alphanumeric() {
name.push(d.to_ascii_uppercase());
chars.next();
} else {
break;
}
}
Tok::Name(name)
}
fn parse_cmp_token(ch: char, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
chars.next();
let next = chars.peek().copied();
let (op, longer) = match (ch, next) {
('<', Some('=')) => ("<=", true),
('<', Some('>')) => ("<>", true),
('>', Some('=')) => (">=", true),
('<', _) => ("<", false),
('>', _) => (">", false),
_ => ("=", false),
};
if longer {
chars.next();
}
Tok::Cmp(op)
}
fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
let mut out = Vec::new();
let mut chars = s.chars().peekable();
@@ -235,46 +279,14 @@ fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
if ch.is_whitespace() {
chars.next();
} else if ch.is_ascii_digit() || ch == '.' {
let mut lit = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
lit.push(d);
chars.next();
} else {
break;
}
}
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
out.push(Tok::Num(x));
out.push(parse_num_token(&mut chars)?);
} else if ch.is_ascii_alphabetic() {
let mut name = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_alphanumeric() {
name.push(d.to_ascii_uppercase());
chars.next();
} else {
break;
}
}
out.push(Tok::Name(name));
out.push(parse_name_token(&mut chars));
} else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') {
out.push(Tok::Op(ch));
chars.next();
} else if matches!(ch, '<' | '>' | '=') {
chars.next();
let next = chars.peek().copied();
let (op, longer) = match (ch, next) {
('<', Some('=')) => ("<=", true),
('<', Some('>')) => ("<>", true),
('>', Some('=')) => (">=", true),
('<', _) => ("<", false),
('>', _) => (">", false),
_ => ("=", false),
};
if longer {
chars.next();
}
out.push(Tok::Cmp(op));
} else if matches!(ch, '<'..='>') {
out.push(parse_cmp_token(ch, &mut chars));
} else {
return Err(syntax(format!("`{ch}` isn't part of a formula")));
}
@@ -468,7 +480,7 @@ impl Parser {
/// `B4` → (row 3, column 1): the row counts from 1 and the column from A.
fn cell_ref(name: &str) -> Option<(usize, usize)> {
let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let letters: String = name.chars().take_while(char::is_ascii_alphabetic).collect();
let digits = name.get(letters.len()..)?;
if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
+2 -3
View File
@@ -195,8 +195,7 @@ impl AutoCorrectPrefs {
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
.find(|(a, _)| {
*a == word
|| (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
*a == word || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
})
.map(|(a, b)| {
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
@@ -249,7 +248,7 @@ impl AutoCorrectPrefs {
let mut v: Vec<String> = NOT_SENTENCE_END
.iter()
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a)))
.map(|a| a.to_string())
.map(ToString::to_string)
.chain(self.exceptions.iter().map(|a| a.to_lowercase()))
.collect();
v.sort();
+1 -1
View File
@@ -181,7 +181,7 @@ impl Store {
let mut entries = BTreeMap::new();
let mut unreadable = false;
for dir in self.directories()? {
let Some(id) = dir.file_name().and_then(|s| s.to_str()) else { continue };
let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue };
let guard = if self.locks.contains_key(id) {
None
} else {
+50 -5
View File
@@ -673,6 +673,10 @@ impl Session {
}
pub fn new(doc: Document) -> Self {
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
}
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
Session {
@@ -692,7 +696,7 @@ impl Session {
find: FindState::default(),
goal_x: None,
page_hint: 0,
registry: Arc::new(crate::cmd::registry()),
registry,
author: "WordCraft User".into(),
initials: String::new(),
painter: None,
@@ -876,6 +880,12 @@ impl Session {
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let typing = self.typing_word();
self.layout_with(typing)
}
/// [`Session::layout`] with `typing` as the word being typed.
fn layout_with(&mut self, typing: Option<wordcraft_layout::TypingWord>) -> Arc<DocLayout> {
let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode };
let width = match mode {
ViewMode::Read => self.view.read_width,
@@ -889,7 +899,6 @@ impl Session {
let hide_deleted = !self.view.show_markup;
let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons());
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
let typing = self.typing_word();
if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout
&& *r == self.rev
&& (*w == width || mode == ViewMode::Print)
@@ -966,6 +975,41 @@ impl Session {
let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?;
Some(TypedWord { at, start, end })
}
/// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931):
/// the command runs on a copy of the document, with this caret and view, and the copy is laid
/// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph
/// paths name the copy's paragraphs, not this document's, and so is the copy's selection,
/// which is where the caret would be. The document, the selection, the undo history and Repeat
/// are untouched. `None` when the command fails or isn't allowed here.
pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc<DocLayout>)> {
// The word being typed keeps going without a squiggle on the preview: the command moves
// the copy's caret off it and may split its paragraph, so it is found again by its place.
let typing = self.typing_word();
let at = self.sel.focus.clone();
let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone());
copy.sel = self.sel.clone();
copy.column = self.column.clone();
copy.widened = self.widened.clone();
copy.view = self.view.clone();
copy.prefs = self.prefs.clone();
copy.path = self.path.clone();
copy.file_name = self.file_name.clone();
copy.author = self.author.clone();
copy.system_region = self.system_region.clone();
copy.editing_lang = self.editing_lang.clone();
// The copy shares the paragraph layouts, so only what the command changed is laid out.
std::mem::swap(&mut copy.cache, &mut self.cache);
let layout = copy.run(id, params).is_ok().then(|| {
let typing = typing.and_then(|t| {
let p = copy.doc.para_at(&at)?;
(t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t })
});
copy.layout_with(typing)
});
std::mem::swap(&mut copy.cache, &mut self.cache);
layout.map(|l| (copy.doc, copy.sel, l))
}
/// A layout for output (PDF, images, print): no proofing marks, print view.
pub fn export_layout(&self) -> Arc<DocLayout> {
self.export_layout_with(false)
@@ -1418,7 +1462,7 @@ impl Session {
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
fn protect_facts(&self) -> crate::cmd::protect::Facts {
let key = (self.document_id, self.rev, self.author.clone());
let mut cache = self.protect_facts.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((k, f)) = cache.as_ref()
&& *k == key
{
@@ -1527,11 +1571,12 @@ impl Session {
let default_language_before = self.prefs.default_language.clone();
let run = spec.run;
let mutates = spec.mutates;
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let mut result = match result {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
}
};
@@ -1571,7 +1616,7 @@ impl Session {
self.note_edit();
}
// Outline view: collapsed headings follow edits; the caret stays where it shows.
crate::cmd::outline::after_command(self, &sel_before);
crate::cmd::outline::after_command(self, &sel_before, self.rev != rev_before);
// Extra selected objects last until something else is edited or selected; formatting
// them keeps them selected, as in Word.
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != sel_before)
+98
View File
@@ -1674,6 +1674,73 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
/// #3931: the page as a command would leave it, laid out without running it on the document:
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
/// history and Repeat stay as they were.
#[test]
fn preview_layout_shows_a_command_without_running_it() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Lanterns"));
run(&mut s, "caret.docEnd", json!({}));
let (doc, sel, rev) = (s.doc.clone(), s.sel.clone(), s.rev());
let cells = |l: &wordcraft_layout::DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).count();
let (_, _, preview) = s.preview_layout("insert.table", &json!({"rows": 4, "cols": 3})).expect("a table can be inserted here");
assert_eq!(cells(&preview), 12, "four rows of three cells");
// At the caret: after "Lanterns", the second block.
let tables: std::collections::BTreeSet<u32> =
preview.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).map(|(_, p)| p.0[0]).collect();
assert_eq!(tables, [1].into(), "the table goes where the caret is");
assert_eq!(s.doc, doc);
assert_eq!((&s.sel, s.rev(), s.dirty), (&sel, rev, false));
assert!(!s.can_undo() && s.last_command.is_none());
assert_eq!(cells(&s.layout()), 0, "the page itself has no table");
assert!(s.preview_layout("no.such", &json!({})).is_none());
}
/// #3931 review: the preview comes back with the document it was laid out from. The table moves
/// the paragraphs after the caret down a block, so this document would draw the layout's
/// paragraphs from the wrong blocks, or from none.
#[test]
fn preview_layout_comes_with_the_document_it_lays_out() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites"));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
let (doc, _, preview) = s.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).expect("a table can be inserted here");
for (story, path) in preview.index.keys().filter(|(st, _)| *st == StoryRef::Body) {
assert!(doc.para(*story, path).is_some(), "{path:?} names a paragraph of the previewed document");
}
let kites = preview.index.keys().find(|(st, p)| *st == StoryRef::Body && doc.para(*st, p).is_some_and(|p| p.text == "Kites"));
assert!(kites.is_some_and(|(_, p)| p.0.len() == 1 && p.0[0] > 1), "Kites is laid out after the table: {kites:?}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Lanterns\nKites", "the document itself has no table");
}
/// #3931 review: the word being typed has no spelling squiggle on the preview either, though
/// the table moves the copy's caret off it and splits its paragraph.
#[test]
fn preview_layout_keeps_the_word_being_typed_unmarked() {
let mut s = Session::new(wordcraft_doc::Document::from_text("helo"));
s.view.proofing = true;
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.insert", json!({"text": " wrld"}));
let marks = |l: &wordcraft_layout::DocLayout, doc: &wordcraft_doc::Document| -> Vec<String> {
let text = &doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![0])).unwrap().text;
l.pages[0]
.items
.iter()
.filter_map(|i| match i {
wordcraft_layout::Placed::Lines { para, path, .. } if path.0 == [0] => {
Some(para.issues.iter().filter(|i| !i.2).map(|i| text[i.0..i.1].to_string()).collect::<Vec<_>>())
}
_ => None,
})
.flatten()
.collect()
};
let doc = s.doc.clone();
assert_eq!(marks(&s.layout(), &doc), ["helo"], "the page itself: only the finished word is marked");
let (copy, _, preview) = s.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
assert_eq!(marks(&preview, &copy), ["helo"]);
}
#[test]
fn delete_row_on_a_merge_start_promotes_the_cell_below() {
use wordcraft_doc::props::VMerge;
@@ -7859,6 +7926,37 @@ fn outline_collapse_hides_descendants_and_expand_restores() {
assert_eq!(s.selected_text(), "One\na\nTwo\nb");
}
/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index)
/// finds them again without scanning the document once per heading, and a caret move doesn't
/// redo the matching.
#[test]
fn outline_many_collapsed_headings_stay_fast_after_edits() {
const N: usize = 8000;
let text: Vec<String> = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect();
let mut d = wordcraft_doc::Document::from_text(&text.join("\n"));
for i in 0..N {
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap();
p.props.style = Some("Heading1".into());
}
let mut s = Session::new(d);
run(&mut s, "view.outline", json!({}));
run(&mut s, "outline.showLevel1", json!({}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "outline.collapse", json!({}));
let collapsed_texts =
|s: &Session| -> Vec<String> { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() };
let before = collapsed_texts(&s);
assert_eq!(before.len(), N, "every heading is collapsed");
s.sel = crate::Selection::caret(Pos::body(1, 0));
for _ in 0..10 {
run(&mut s, "text.newParagraph", json!({}));
}
for _ in 0..20 {
run(&mut s, "caret.right", json!({}));
}
assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed");
}
/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at
/// their pixel size are fitted to the text width when the file is opened (and when pasted),
/// keeping their proportions; small pictures keep their size.
+30 -29
View File
@@ -99,12 +99,12 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if w.len() <= id {
w.resize(id + 1, None);
}
@@ -933,7 +933,7 @@ pub fn base_style(style: &str) -> &str {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
@@ -1023,11 +1023,11 @@ impl FontDb {
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
}
// Misses remembered while it was off may be found now.
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
*self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = RunFallbackCache::default();
self.fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner).map.clear();
*self.run_fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner) = RunFallbackCache::default();
#[cfg(target_arch = "wasm32")]
let _ = on;
}
@@ -1041,14 +1041,14 @@ impl FontDb {
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The system font catalog, scanned first if it hasn't been yet.
#[cfg(not(target_arch = "wasm32"))]
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
self.ensure_catalog();
self.catalog.read().unwrap_or_else(|e| e.into_inner())
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. Like
@@ -1184,7 +1184,7 @@ impl FontDb {
}
}
}
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut added = 0;
for f in new {
if !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)) {
@@ -1243,7 +1243,7 @@ impl FontDb {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -1263,7 +1263,7 @@ impl FontDb {
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
self.catalog_gen.fetch_add(1, Ordering::AcqRel);
n
}
@@ -1273,7 +1273,7 @@ impl FontDb {
/// its styles. Returns whether any face was added.
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let _loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
let _loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another lookup may have loaded it while this one waited.
if self.is_loaded(family) {
return true;
@@ -1282,7 +1282,7 @@ impl FontDb {
let files = paths.iter().filter_map(|p| std::fs::read(p).ok()).map(|b| (b, None)).collect();
let added = self.add_fonts(files) > 0;
// The fallback searches' index: these files are loaded fonts now.
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
let mut index = self.coverage.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for file in index.files.iter_mut().filter(|f| paths.contains(&f.path)) {
file.used = true;
}
@@ -1471,7 +1471,7 @@ impl FontDb {
let mut f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?;
f.local_names = base.local_names.clone();
let f = Arc::new(f);
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
Some(f)
}
@@ -1540,7 +1540,7 @@ impl FontDb {
fn cached_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let n = self.read_faces().len();
{
let cache = self.fallbacks.read().unwrap_or_else(|e| e.into_inner());
let cache = self.fallbacks.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.faces == n
&& let Some(hit) = cache.map.get(&(c, exclude))
{
@@ -1554,7 +1554,7 @@ impl FontDb {
if n != self.read_faces().len() {
return found;
}
let mut cache = self.fallbacks.write().unwrap_or_else(|e| e.into_inner());
let mut cache = self.fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.faces != n || cache.map.len() >= FALLBACK_CACHE_MAX {
cache.map.clear();
cache.faces = n;
@@ -1587,7 +1587,7 @@ impl FontDb {
}
#[cfg(not(target_arch = "wasm32"))]
{
let enabled = self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled;
let enabled = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled;
if enabled {
self.load_cataloged(FAMILY);
}
@@ -1599,7 +1599,7 @@ impl FontDb {
/// one search at a time.
#[cfg(not(target_arch = "wasm32"))]
fn system_search(&self, c: char, exclude: u32) -> (Option<Arc<FontFace>>, usize) {
let _searching = self.searching.lock().unwrap_or_else(|e| e.into_inner());
let _searching = self.searching.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// A search that held the lock first may have loaded a font covering `c`: choose among the
// loaded faces again before loading more.
let (found, n) = self.loaded_fallback(c, exclude);
@@ -1660,7 +1660,7 @@ impl FontDb {
let key = (set, exclude, bold, italic);
let n = self.read_faces().len();
{
let cache = self.run_fallbacks.read().unwrap_or_else(|e| e.into_inner());
let cache = self.run_fallbacks.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.faces == n
&& let Some(hit) = cache.map.get(&key)
{
@@ -1671,7 +1671,7 @@ impl FontDb {
// meanwhile by another thread (one its search, or a family named by a document) must make
// a miss count as stale, not as the answer for the new count.
let (found, n) = self.search_run(&key.0, exclude, bold, italic);
let mut cache = self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner());
let mut cache = self.run_fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.faces != n || cache.map.len() >= RUN_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > RUN_FALLBACK_CACHE_CHARS {
cache.map.clear();
cache.faces = n;
@@ -1750,7 +1750,7 @@ impl FontDb {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !sys.enabled || sys.misses.contains(&c) {
return false;
}
@@ -1764,7 +1764,7 @@ impl FontDb {
if self.is_loaded(fam) || !cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
continue;
}
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).tried.insert(fam) {
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).tried.insert(fam) {
continue;
}
self.load_cataloged(fam);
@@ -1775,7 +1775,7 @@ impl FontDb {
if self.indexed_fallback(c) {
return true;
}
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if sys.misses.len() >= SYS_MISSES_MAX {
sys.misses.clear();
}
@@ -1795,7 +1795,7 @@ impl FontDb {
/// before the Regular.
#[cfg(not(target_arch = "wasm32"))]
fn indexed_run_fallback(&self, chars: &[char]) {
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled || chars.iter().any(|c| c.is_control()) {
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled || chars.iter().any(|c| c.is_control()) {
return;
}
self.load_indexed(chars, true);
@@ -1811,13 +1811,14 @@ impl FontDb {
let covered = |db: &FontDb| db.read_faces().iter().any(|f| chars.iter().all(|c| f.covers(*c)));
let cps: Vec<u32> = chars.iter().map(|c| u32::from(*c)).collect();
let maps_all = |ranges: &[(u32, u32)]| cps.iter().all(|cp| ranges_hold(ranges, *cp));
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
let mut index = self.coverage.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let generation = self.catalog_gen.load(Ordering::Acquire);
if index.generation != Some(generation) {
// A new scan (or the first search): keep what is known of files still installed.
let mut known: HashMap<std::path::PathBuf, IndexedFile> =
std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect();
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
let mut paths: Vec<std::path::PathBuf> =
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
let files = paths
@@ -1880,11 +1881,11 @@ impl FontDb {
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let p = Arc::new(face.glyph_outline(gid));
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
+1 -1
View File
@@ -368,7 +368,7 @@ impl FontDb {
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str())))
.filter(|p| p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str())))
.collect();
paths.sort();
for p in paths {
+2 -2
View File
@@ -260,7 +260,7 @@ pub fn parse(b: &[u8]) -> Option<MathTable> {
pub fn table(face: &FontFace) -> Option<Arc<MathTable>> {
static CACHE: OnceLock<Mutex<HashMap<u32, Option<Arc<MathTable>>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) {
if let Some(t) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&face.id()) {
return t.clone();
}
let t = face
@@ -268,7 +268,7 @@ pub fn table(face: &FontFace) -> Option<Arc<MathTable>> {
.and_then(|f| f.table_data(skrifa::raw::types::Tag::new(b"MATH")).map(|d| d.as_bytes().to_vec()))
.and_then(|bytes| parse(&bytes))
.map(Arc::new);
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), t.clone());
cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(face.id(), t.clone());
t
}
+4 -4
View File
@@ -389,7 +389,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
let cacheable = family.len() <= FAMILY_KEY_MAX;
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
{
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.0 != generation {
*c = (generation, HashMap::new());
}
@@ -409,7 +409,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
};
let face = db.face(&fam, style_name(bold, italic));
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cacheable && c.0 == generation {
if c.1.len() >= FAMILY_CACHE_MAX {
c.1.clear();
@@ -431,7 +431,7 @@ fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
static CACHE: OnceLock<Mutex<HashMap<u32, (f64, f64)>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) {
if let Some(v) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&face.id()) {
return *v;
}
let mut v = (face.ascent.max(1.0), face.descent.abs());
@@ -447,7 +447,7 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
v = m;
}
}
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), v);
cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(face.id(), v);
v
}
+5 -6
View File
@@ -366,9 +366,8 @@ impl Lexer<'_> {
self.pos += after_tag;
self.skip_past(">");
return;
} else {
self.pos += k + 2;
}
self.pos += k + 2;
}
}
@@ -408,7 +407,7 @@ impl Lexer<'_> {
self.skip_past(">");
return Some(Tok::End(name));
}
if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) {
if !b.get(1).is_some_and(u8::is_ascii_alphabetic) {
self.pos += 1;
return Some(Tok::Text("<".into()));
}
@@ -419,7 +418,7 @@ impl Lexer<'_> {
let mut attrs = Vec::new();
let mut self_close = false;
loop {
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
match b.get(i) {
@@ -448,13 +447,13 @@ impl Lexer<'_> {
i += 1;
continue;
}
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
let mut val = String::new();
if b.get(i) == Some(&b'=') {
i += 1;
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
match b.get(i) {
+3 -3
View File
@@ -778,13 +778,13 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\');
o.push_str(&name);
i += 1 + name.len();
if chars.get(i).is_some_and(|n| n.is_ascii_alphabetic()) {
if chars.get(i).is_some_and(char::is_ascii_alphabetic) {
o.push(' ');
}
}
continue;
}
'%' | '#' | '&' | '$' => {
'#'..='&' => {
o.push('\\');
o.push(c);
}
@@ -796,7 +796,7 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\');
o.push_str(name);
// A control word needs a space only before a letter.
if chars.get(i + 1).is_some_and(|n| n.is_ascii_alphabetic()) {
if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) {
o.push(' ');
}
}
+5 -5
View File
@@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option<Marker> {
let (m, len) = if matches!(first, '-' | '+' | '*') {
(Marker { ordered: false, ch: first, len: 1, start: 1 }, 1)
} else {
let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
let digits = t.chars().take_while(char::is_ascii_digit).count();
if !(1..=9).contains(&digits) {
return None;
}
@@ -607,7 +607,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
i += 1;
}
// Blank lines at the end of an item belong between items.
while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) {
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
item.pop();
}
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start };
@@ -833,7 +833,7 @@ fn unescape(s: &str) -> String {
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(n) = chars.peek().copied().filter(|n| n.is_ascii_punctuation())
&& let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation)
{
out.push(n);
chars.next();
@@ -852,7 +852,7 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
let at = from + k;
let before = lower.get(..at)?.chars().next_back();
let after = lower.get(at + name.len()..)?.trim_start();
if before.is_some_and(|c| c.is_whitespace()) && after.starts_with('=') {
if before.is_some_and(char::is_whitespace) && after.starts_with('=') {
let v = after.get(1..)?.trim_start();
let q = v.chars().next()?;
return Some(if q == '"' || q == '\'' {
@@ -1257,7 +1257,7 @@ fn escape_line_start(s: String) -> String {
|| (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-'))
|| thematic_break(t)
|| {
let d = t.chars().take_while(|c| c.is_ascii_digit()).count();
let d = t.chars().take_while(char::is_ascii_digit).count();
d > 0
&& t.get(d..).is_some_and(|r| {
r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")"
+2 -2
View File
@@ -88,10 +88,10 @@ fn run(r: &MRun, o: &mut String, eq_arr: bool) {
let cs: Vec<char> = r.text.chars().collect();
let mut i = 0;
while let Some(&c) = cs.get(i) {
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) {
let mut num = String::new();
while let Some(&d) =
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(char::is_ascii_digit))
{
num.push(d);
i += 1;
+2 -2
View File
@@ -2,9 +2,9 @@
//! reader (#863).
use std::collections::HashSet;
use wordcraft_doc::{Part, PartKind};
use wordcraft_doc::{Document, Part, PartKind};
use super::*;
use super::{Body, Ev, FBlock, Pages, SectionProps, Styles, events, get, model};
/// Most XML events kept for all headers and footers together; a slot past it is left out.
const MAX_EVENTS: usize = 1 << 20;
+3 -3
View File
@@ -2150,7 +2150,7 @@ impl Reader {
self.pending_high = Some(u);
} else {
let c = match self.pending_high.take() {
Some(h) if (0xDC00..0xE000).contains(&u) => char::decode_utf16([h, u]).next().and_then(|r| r.ok()).unwrap_or('\u{FFFD}'),
Some(h) if (0xDC00..0xE000).contains(&u) => char::decode_utf16([h, u]).next().and_then(Result::ok).unwrap_or('\u{FFFD}'),
_ => char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}'),
};
self.ch(c);
@@ -2638,7 +2638,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let Some(&n) = bytes.get(i + 1) else { break };
if n.is_ascii_alphabetic() {
let mut j = i + 1;
while bytes.get(j).is_some_and(|b| b.is_ascii_alphabetic()) && j - i <= 32 {
while bytes.get(j).is_some_and(u8::is_ascii_alphabetic) && j - i <= 32 {
j += 1;
}
let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or("");
@@ -2648,7 +2648,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
k += 1;
}
let d0 = k;
while bytes.get(k).is_some_and(|b| b.is_ascii_digit()) && k - d0 < 10 {
while bytes.get(k).is_some_and(u8::is_ascii_digit) && k - d0 < 10 {
k += 1;
}
let param = if k > d0 {
+8 -2
View File
@@ -724,7 +724,8 @@ fn number(x: f64) -> String {
return x.to_string();
}
if x.fract() == 0.0 && x.abs() < 1e15 {
return format!("{x:.0}");
// `+ 0.0` turns negative zero into positive zero, which would print as "-0".
return format!("{:.0}", x + 0.0);
}
let rounded: f64 = format!("{x:.14e}").parse().unwrap_or(x);
if rounded.abs() >= 1e15 || rounded.abs() < 1e-9 { format!("{rounded:E}") } else { rounded.to_string() }
@@ -788,7 +789,7 @@ fn iso_datetime(v: &str) -> Option<String> {
let y: i64 = it.next()?.parse().ok()?;
let m: u32 = it.next()?.parse().ok()?;
let d_part = it.next()?;
let d_digits: String = d_part.chars().take_while(|c| c.is_ascii_digit()).collect();
let d_digits: String = d_part.chars().take_while(char::is_ascii_digit).collect();
let d: u32 = d_digits.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
@@ -1006,6 +1007,11 @@ mod tests {
use super::*;
use std::io::Write;
#[test]
fn negative_zero_formats_as_zero() {
assert_eq!(number(-0.0), "0");
}
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+4 -14
View File
@@ -692,20 +692,10 @@ fn shape_path(kind: ShapeKind, r: &Rect, corner: Option<u32>) -> Option<Path> {
pb.move_to(x0, y0);
pb.line_to(x1, y1);
}
ShapeKind::Arrow => {
let h = r.h;
poly(
&mut pb,
&[
(x0, cy - h * 0.2),
(x1 - h * 0.5, cy - h * 0.2),
(x1 - h * 0.5, y0),
(x1, cy),
(x1 - h * 0.5, y1),
(x1 - h * 0.5, cy + h * 0.2),
(x0, cy + h * 0.2),
],
)
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => {
let pts: Vec<(f32, f32)> =
wordcraft_doc::wrap::arrow_outline(kind, r.w, r.h).iter().map(|&(u, v)| (x0 + u * r.w, y0 + v * r.h)).collect();
poly(&mut pb, &pts)
}
ShapeKind::Star => {
let pts: Vec<(f32, f32)> = (0..10)
+6 -3
View File
@@ -426,6 +426,9 @@ fn shapes_have_paths() {
ShapeKind::Star,
ShapeKind::Heart,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Line,
ShapeKind::Diamond,
ShapeKind::Triangle,
@@ -531,7 +534,7 @@ fn unsubsettable_font(family: &str, gid: u16) -> Vec<u8> {
i += 1;
}
};
let wide = |s: &str| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect::<Vec<u8>>();
let wide = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
assert_eq!(family.len(), "Source Sans 3".len());
rename(&mut f, &wide("Source Sans 3"), &wide(family));
rename(&mut f, b"Source Sans 3", family.as_bytes());
@@ -1208,10 +1211,10 @@ fn cosmetic_metafile_pens_are_thin_but_visible() {
fn emf_text_records(text: &str) -> Vec<Vec<u8>> {
let mut font = [82i32.to_le_bytes(), 104u32.to_le_bytes(), 1i32.to_le_bytes(), (-20i32).to_le_bytes()].concat();
font.extend([0i32, 0, 0, 400, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(u16::to_le_bytes).collect();
name.resize(64, 0);
font.extend(name);
let units: Vec<u8> = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let units: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect();
let padded = units.len().div_ceil(4) * 4;
let mut out = [84u32.to_le_bytes(), ((76 + padded) as u32).to_le_bytes()].concat();
out.extend([0i32, 0, 100, 100, 1, 0, 0, 5, 40, text.encode_utf16().count() as i32, 76, 0, 0, 0, 0, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
+5 -5
View File
@@ -31,11 +31,11 @@ const NAMES: &[&str] = &["cs_CZ", "cs-CZ", "cs"];
/// [`install_dictionary`], or an empty one when none is available (Czech spelling is off
/// then; see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).dict.as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.dict.get_or_insert(loaded).clone()
}
@@ -56,7 +56,7 @@ fn load_installed() -> AffixDict {
/// UTF-8), for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.dict = Some(Arc::new(d));
slot.in_memory = true;
Ok(())
@@ -65,7 +65,7 @@ pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.dict = None;
slot.in_memory = false;
}
@@ -75,7 +75,7 @@ pub fn reload_dictionary() {
/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a
/// matching file also happens to exist.
pub fn installed_path() -> Option<std::path::PathBuf> {
if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory {
if DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).in_memory {
return None;
}
crate::hunspell::find(NAMES).map(|(_, dic)| dic)
+4 -4
View File
@@ -25,11 +25,11 @@ const NAMES: &[&str] = &["hr_HR", "hr-HR", "hr"];
/// [`install_dictionary`], or an empty one when none is available (Croatian spelling is off
/// then; see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.get_or_insert(loaded).clone()
}
@@ -51,14 +51,14 @@ fn load_installed() -> AffixDict {
/// UTF-8), for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the Hunspell `hr_HR` dictionary found in the dictionaries folder or the
+6 -6
View File
@@ -27,7 +27,7 @@ static LOAD_ERRORS: RwLock<Vec<(String, PathBuf, String)>> = RwLock::new(Vec::ne
/// Remember (or, for `Ok`, forget) why the dictionary `key` found at `dic` failed to load, and
/// hand the result back unchanged.
pub fn note_load<T>(key: &str, dic: &Path, result: Result<T, String>) -> Result<T, String> {
let mut errors = LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner());
let mut errors = LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner);
errors.retain(|(k, _, _)| k != key);
if let Err(e) = &result {
errors.push((key.to_string(), dic.to_path_buf(), e.clone()));
@@ -37,19 +37,19 @@ pub fn note_load<T>(key: &str, dic: &Path, result: Result<T, String>) -> Result<
/// Forget the load error of the dictionary `key` (none found, or it is about to be searched for again).
pub fn clear_load_error(key: &str) {
LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner()).retain(|(k, _, _)| k != key);
LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|(k, _, _)| k != key);
}
/// The `.dic` file and the reason of the last failed load of the dictionary `key`, if any.
pub fn load_error(key: &str) -> Option<(PathBuf, String)> {
LOAD_ERRORS.read().unwrap_or_else(|e| e.into_inner()).iter().find(|(k, _, _)| k == key).map(|(_, p, e)| (p.clone(), e.clone()))
LOAD_ERRORS.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|(k, _, _)| k == key).map(|(_, p, e)| (p.clone(), e.clone()))
}
/// Search `dir` before the system locations (a "dictionaries folder" setting); `None` clears it.
/// Takes effect for dictionaries not loaded yet.
pub fn set_dictionary_dir(dir: Option<PathBuf>) {
*EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir;
LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner()).clear(); // they belong to the old search path
*EXTRA_DIR.write().unwrap_or_else(std::sync::PoisonError::into_inner) = dir;
LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner).clear(); // they belong to the old search path
}
/// The LibreOffice extension directories a Hunspell dictionary name may live in: LibreOffice's
@@ -67,7 +67,7 @@ fn lo_ext_dirs(name: &str) -> Vec<String> {
/// language is being searched for, adds LibreOffice's extension folders for it (`dict-pt-BR`).
pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone() {
dirs.push(d);
}
if cfg!(target_arch = "wasm32") {
+4 -4
View File
@@ -30,11 +30,11 @@ const CLITICS: &[&str] = &["se", "nya", "ku", "mu", "lah", "kah", "pun"];
/// [`install_dictionary`], or an empty one when none is available (Indonesian spelling is off
/// then; see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.get_or_insert(loaded).clone()
}
@@ -55,14 +55,14 @@ fn load_installed() -> AffixDict {
/// UTF-8), for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the Hunspell `id_ID` dictionary found in the dictionaries folder or the
+1 -1
View File
@@ -276,7 +276,7 @@ fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
}
let mut after = text.get(*b..).unwrap_or("").chars();
let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue };
let ends = after.next().is_none_or(|c| c.is_whitespace());
let ends = after.next().is_none_or(char::is_whitespace);
let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘');
if ends && !quoted {
v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()]));
+4 -4
View File
@@ -34,11 +34,11 @@ const NAMES: &[&str] = &["it_IT", "it_CH", "it-IT", "it"];
/// [`install_dictionary`], or an empty one when none is available (Italian spelling is off
/// then; see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.get_or_insert(loaded).clone()
}
@@ -59,14 +59,14 @@ fn load_installed() -> AffixDict {
/// UTF-8), for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the Hunspell `it_IT` dictionary found in the dictionaries folder or the
+10 -10
View File
@@ -130,14 +130,14 @@ fn dictionary_key(w: &str) -> String {
/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language.
pub fn add_word(w: &str) {
if user_words().write().unwrap_or_else(|e| e.into_inner()).insert(dictionary_key(w)) {
if user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
}
}
/// Take a word out of the user dictionary (File › Options › Proofing). Returns whether it was there.
pub fn remove_word(w: &str) -> bool {
let removed = user_words().write().unwrap_or_else(|e| e.into_inner()).remove(&dictionary_key(w));
let removed = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&dictionary_key(w));
if removed {
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
}
@@ -152,7 +152,7 @@ fn ignored_words() -> &'static RwLock<HashSet<String>> {
/// Ignore a word everywhere until the app closes ("Ignore All"). Unlike [`add_word`] it isn't
/// part of the user dictionary: it is never listed, saved or loaded, as in Word.
pub fn ignore_word(w: &str) {
if ignored_words().write().unwrap_or_else(|e| e.into_inner()).insert(dictionary_key(w)) {
if ignored_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
}
}
@@ -220,13 +220,13 @@ pub fn dictionary_generation() -> u64 {
/// the entries are kept ([`dictionary_key`]).
pub(crate) fn in_user_dictionary(lower: &str) -> bool {
let key = composed(lower);
user_words().read().unwrap_or_else(|e| e.into_inner()).contains(&*key)
|| ignored_words().read().unwrap_or_else(|e| e.into_inner()).contains(&*key)
user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
|| ignored_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
}
/// Words in the user dictionary.
pub fn user_dictionary() -> Vec<String> {
let mut v: Vec<String> = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect();
let mut v: Vec<String> = user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().cloned().collect();
v.sort();
v
}
@@ -274,7 +274,7 @@ fn read_dictionary_file(path: &std::path::Path) -> Vec<String> {
/// never a panic and never an error that stops startup. Returns the words added.
pub fn load_user_dictionary(path: &std::path::Path) -> Vec<String> {
let words = read_dictionary_file(path);
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
let mut set = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut added = Vec::new();
for w in words {
if set.len() >= MAX_CUSTOM_WORDS {
@@ -300,7 +300,7 @@ pub fn save_user_dictionary(path: &std::path::Path, removed: &[String]) -> std::
let removed: HashSet<String> = removed.iter().map(|w| dictionary_key(w)).collect();
let on_disk = read_dictionary_file(path);
{
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
let mut set = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut changed = false;
for w in on_disk {
if set.len() >= MAX_CUSTOM_WORDS {
@@ -342,7 +342,7 @@ const COMMON: &str = "a an the and or but if of to in on at by for with from as
const EXTRA_EN: &str = include_str!("../../../assets/spelling/en-extra.txt");
fn extra_words() -> &'static HashSet<&'static str> {
static E: OnceLock<HashSet<&'static str>> = OnceLock::new();
static E: OnceLock<HashSet<&str>> = OnceLock::new();
E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
}
@@ -943,7 +943,7 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec<Issue> {
if gap == " "
&& (w0 == "an" || w0 == "An")
&& w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c))
&& !w1.chars().all(|c| c.is_uppercase())
&& !w1.chars().all(char::is_uppercase)
{
v.push(Issue {
start: *a0,
+4 -4
View File
@@ -33,11 +33,11 @@ const NAMES: &[&str] = &["pl_PL", "pl-PL", "pl"];
/// [`install_dictionary`], or an empty one when none is available (Polish spelling is off then;
/// see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.get_or_insert(loaded).clone()
}
@@ -58,14 +58,14 @@ fn load_installed() -> AffixDict {
/// for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the installed Polish dictionary, searching the dictionaries folder and
+4 -4
View File
@@ -32,11 +32,11 @@ const NAMES: &[&str] = &["pt_BR", "pt-BR", "pt"];
/// [`install_dictionary`], or an empty one when none is available (Portuguese spelling is off
/// then; see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.get_or_insert(loaded).clone()
}
@@ -57,14 +57,14 @@ fn load_installed() -> AffixDict {
/// UTF-8), for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the installed Portuguese dictionary, searching the dictionaries folder and
+6 -6
View File
@@ -52,11 +52,11 @@ const NAMES: &[&str] = &["ru_RU", "ru-RU", "ru"];
/// [`install_dictionary`], or an empty one when none is available (Russian spelling is off then;
/// see [`has_dictionary`]).
pub fn dictionary() -> Arc<AffixDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).dict.as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread (or an install) may have filled it meanwhile: that one wins.
slot.dict.get_or_insert(loaded).clone()
}
@@ -77,7 +77,7 @@ fn load_installed() -> AffixDict {
/// for example one the user picked. On error the current dictionary stays.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = AffixDict::from_hunspell(aff, dic, &[])?;
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.dict = Some(Arc::new(d));
slot.in_memory = true;
Ok(())
@@ -86,7 +86,7 @@ pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
pub fn reload_dictionary() {
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.dict = None;
slot.in_memory = false;
}
@@ -96,7 +96,7 @@ pub fn reload_dictionary() {
/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a
/// matching file also happens to exist.
pub fn installed_path() -> Option<std::path::PathBuf> {
if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory {
if DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).in_memory {
return None;
}
crate::hunspell::find(NAMES).map(|(_, dic)| dic)
@@ -109,7 +109,7 @@ pub fn has_dictionary() -> bool {
/// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)?
pub(crate) fn is_cyrillic(c: char) -> bool {
matches!(c, '\u{0400}'..='\u{04FF}' | '\u{0500}'..='\u{052F}')
matches!(c, '\u{0400}'..='\u{052F}')
}
/// Particles joined to a word with a hyphen (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`,
+4 -4
View File
@@ -30,11 +30,11 @@ const NAMES: &[&str] = &["tr_TR", "tr-TR", "tr"];
/// The Turkish dictionary: the installed Hunspell `tr_TR` (read on first use), one given with
/// [`install_dictionary`], or an empty one when none is available.
pub fn dictionary() -> Arc<TrDict> {
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
return d.clone();
}
let loaded = Arc::new(load_installed());
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.get_or_insert(loaded).clone()
}
@@ -61,13 +61,13 @@ fn load_installed() -> TrDict {
/// Use this Turkish dictionary from now on.
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
let d = TrDict::from_hunspell(aff, dic)?;
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
Ok(())
}
/// Reload the dictionary on next check.
pub fn reload_dictionary() {
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The `.dic` file of the Hunspell `tr_TR` dictionary found in the dictionaries folder or the
+8 -1
View File
@@ -1,6 +1,13 @@
//! Decoded-media cache: hits need the same allocation, and the byte budget bounds what is kept.
use super::*;
use std::collections::HashMap;
use std::sync::Arc;
use wordcraft_doc::Document;
use wordcraft_doc::props::Rgb;
use wordcraft_layout::display::{Draw, Stroke};
use super::{CACHE_BYTES, MediaCache, RenderOptions, render_items, wave_path};
#[test]
fn media_cache_only_hits_the_same_allocation() {
+20 -15
View File
@@ -181,7 +181,7 @@ impl<T: Clone> MediaCache<T> {
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn images() -> &'static Mutex<MediaCache<Option<Arc<Pixmap>>>> {
@@ -1188,17 +1188,13 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option<u32>) -> BezPa
p.line_to((r.x1, r.y1));
p
}
ShapeKind::Arrow => {
let h = r.height();
poly(&[
(r.x0, cy - h * 0.2),
(r.x1 - h * 0.5, cy - h * 0.2),
(r.x1 - h * 0.5, r.y0),
(r.x1, cy),
(r.x1 - h * 0.5, r.y1),
(r.x1 - h * 0.5, cy + h * 0.2),
(r.x0, cy + h * 0.2),
])
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => {
let (w, h) = (r.width(), r.height());
let pts: Vec<(f64, f64)> = wordcraft_doc::wrap::arrow_outline(kind, w as f32, h as f32)
.iter()
.map(|&(u, v)| (r.x0 + f64::from(u) * w, r.y0 + f64::from(v) * h))
.collect();
poly(&pts)
}
ShapeKind::Star => {
let mut pts = Vec::new();
@@ -2192,9 +2188,18 @@ mod tests {
#[test]
fn shapes_have_paths() {
let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0);
for k in
[ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::FlowchartOr, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line]
{
for k in [
ShapeKind::Rectangle,
ShapeKind::Ellipse,
ShapeKind::FlowchartOr,
ShapeKind::Star,
ShapeKind::Heart,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Line,
] {
assert!(!shape_path(k, r, None).elements().is_empty());
}
}
+5 -2
View File
@@ -1,9 +1,12 @@
//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour.
use super::*;
use wordcraft_layout::display::page_display;
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
use wordcraft_layout::display::{DisplayOptions, Draw, page_display};
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
use super::{RenderOptions, Rendered, render_page};
/// Colour emoji families the system fallback may pick.
const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"];
+1 -1
View File
@@ -87,7 +87,7 @@ fn locales(dir: &std::path::Path) -> Result<Vec<(String, Option<std::path::PathB
let mut out = Vec::new();
for entry in list.flatten() {
let path = entry.path();
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
let Some(code) = path.file_name().and_then(std::ffi::OsStr::to_str).map(str::to_string) else { continue };
let app = path.join(APP_CATALOG);
if !app.is_file() {
continue;
+154 -9
View File
@@ -199,8 +199,19 @@ pub struct CanvasState {
pub(crate) tab_drag: Option<crate::ruler_tabs::TabDrag>,
/// Whether the I-beam over the page is drawn rather than the system's (`ibeam`).
pub(crate) drawn_ibeam: bool,
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
pub(crate) table_preview: Option<(usize, usize)>,
/// The last previewed table's layout, the copy of the document it was laid out from and the
/// copy's selection, with
/// what it was laid out for (rows, columns, document id, revision, selection, and the
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
/// document out every frame. Set only while the page shows the preview.
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
}
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
impl CanvasState {
/// Stop a text-selection drag when the pointer gesture becomes an object drag.
pub(crate) fn cancel_text_drag(&mut self) {
@@ -310,6 +321,8 @@ impl Default for CanvasState {
tab_kind: Default::default(),
tab_drag: None,
drawn_ibeam: crate::ibeam::DRAWN,
table_preview: None,
table_preview_layout: None,
}
}
}
@@ -935,6 +948,47 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
/// Draw the document canvas in the space `ui` has left, one frame: the pages, caret, selection
/// and rulers, with the mouse editing and the pointer shape over them.
/// The layout the page shows: the document's own, or with the table Insert › Table's grid is
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
/// change only when a square is clicked.
fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
let Some((rows, cols)) = app.canvas.table_preview.take() else {
app.canvas.table_preview_layout = None;
return app.session.layout();
};
// The caret can move, or the view change, with the grid still hovered: the table then goes
// somewhere else, or the page is laid out to another width.
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
&& k.0 == key.0
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
{
return l.clone();
}
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
Some((doc, sel, l)) => {
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
l
}
None => {
app.canvas.table_preview_layout = None;
app.session.layout()
}
}
}
/// The document the pages of [`shown_layout`] are drawn from: the previewed copy while the page
/// shows Insert › Table's preview, whose paragraph paths are the layout's (#3931).
fn page_doc(app: &WordApp) -> &wordcraft_doc::Document {
app.canvas.table_preview_layout.as_ref().map_or(&app.session.doc, |(_, doc, _, _)| doc)
}
/// The previewed copy's selection while the page shows Insert › Table's preview: the caret and
/// selection are drawn from it, as the document's own name other places in the preview's layout.
fn preview_sel(app: &WordApp) -> Option<&wordcraft_engine::Selection> {
app.canvas.table_preview_layout.as_ref().map(|(_, _, sel, _)| sel)
}
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
@@ -996,7 +1050,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.alt_code.clear();
}
crate::ink::replay_tick(app, ui.ctx());
let layout = app.session.layout();
let layout = shown_layout(app);
if let Some(anchor) = reflow_anchor
&& let Some(page) = reading_anchor_page(&layout, &anchor)
{
@@ -1081,7 +1135,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
// Where the selection really ends, also when it took whole table cells.
let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint);
let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint);
// Editing a header/footer (or a note) dims the body; once per frame, for every page.
let dim_body = dims_body(app, &layout);
if let Some(c) = caret
@@ -1269,7 +1323,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let mut opts = page_render_options(app, dim_body);
// Only on the page it is on: the `Pos` can't match an object of another page.
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts);
let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts);
let ci = page_image(&img);
match app.canvas.textures.get_mut(&i) {
Some((k, tex)) => {
@@ -1328,7 +1382,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// The tiny subtraction avoids a 1px overshoot from round-tripping f32
// pixels through page points and render_area's ceil() conversion.
let img = wordcraft_render::render_area(
&app.session.doc,
page_doc(app),
page,
0.0,
start_px as f32 / scale_px,
@@ -1451,8 +1505,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
} else {
balloons(app, ui, &painter, &rects, &layout, geo.scale, view_rect);
}
// Find › Reading Highlight.
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
// Find › Reading Highlight. Its places are the document's, not the preview's.
let marks: Vec<(Pos, Pos)> =
if preview_sel(app).is_some() { Vec::new() } else { app.session.find_highlights().iter().take(5000).cloned().collect() };
for (a, b) in &marks {
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
if let Some(pr) = rects.get(pi) {
@@ -1465,7 +1520,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// Selection (a selected object shows its frame instead): whole table cells shade the
// cells (an empty one too), a column selection each row's piece, text running into or
// across a table the whole cells of the rows it takes.
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
let selected: Vec<_> = if let Some(sel) = preview_sel(app) {
let (a, b) = sel.ordered();
if a == b {
Vec::new()
} else {
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, page_doc(app), &a, &b, app.session.page_hint)
}
} else if crate::objects::selected(app).is_some() {
Vec::new()
} else if let Some(range) = app.session.cell_range() {
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
@@ -1579,7 +1641,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
if !math_caret
&& let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint)
&& let Some(c) = layout.caret_on(&preview_sel(app).unwrap_or(&app.session.sel).focus, app.session.page_hint)
&& let Some(pr) = rects.get(c.page)
{
let scale = layout.pages.get(c.page).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
@@ -1632,7 +1694,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
[pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
};
// The caret glides and fades like Word's (`caret_glide`).
let shown = app.session.sel.is_collapsed() && focused;
let shown = preview_sel(app).unwrap_or(&app.session.sel).is_collapsed() && focused;
let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on);
if shown && look.opacity > 0.0 {
let color = preedit_color.unwrap_or_else(|| caret_color(app));
@@ -3645,6 +3707,89 @@ mod tests {
use super::*;
use crate::table_resize::tests::{H, cell, screen, steps, table_window};
/// #3931: while Insert › Table's grid asks for a preview the page is laid out with that
/// table; the document, its revision and undo history stay as they were, and without a
/// request the page is the document's own again.
#[test]
fn the_page_shows_the_table_the_grid_previews() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default());
let cells = |l: &DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).count();
let (doc, rev) = (app.session.doc.clone(), app.session.rev());
app.canvas.table_preview = Some((2, 3));
assert_eq!(cells(&shown_layout(&mut app)), 6);
assert_eq!(app.canvas.table_preview, None, "a request lasts one frame");
assert_eq!((&app.session.doc, app.session.rev(), app.session.can_undo()), (&doc, rev, false));
app.canvas.table_preview = Some((2, 3));
let again = shown_layout(&mut app);
app.canvas.table_preview = Some((2, 3));
assert!(std::sync::Arc::ptr_eq(&again, &shown_layout(&mut app)), "a still pointer reuses the preview");
assert_eq!(cells(&shown_layout(&mut app)), 0, "no request: the document's own page");
}
/// #3931 review: the preview's pages are drawn from the copy they were laid out from. The
/// table moves "Kites" down a block, so the document's own block there is not its paragraph.
#[test]
fn the_previewed_pages_are_drawn_from_the_previewed_document() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
app.session.run("caret.end", &json!({})).unwrap();
app.canvas.table_preview = Some((2, 2));
let layout = shown_layout(&mut app);
let text = |doc: &wordcraft_doc::Document, story, path| doc.para(story, path).map(|p| p.text.clone());
let mut kites = 0;
for it in layout.pages.iter().flat_map(|p| &p.items) {
let Placed::Lines { story, path, .. } = it else { continue };
let shown = text(page_doc(&app), *story, path);
assert!(shown.is_some(), "{path:?} names a paragraph of the drawn document");
kites += usize::from(shown.as_deref() == Some("Kites"));
}
assert_eq!(kites, 1, "Kites is drawn as itself");
assert_eq!(shown_layout(&mut app).index, app.session.layout().index);
assert!(std::ptr::eq(page_doc(&app), &app.session.doc), "no preview: the document's own pages");
}
/// #3931 review: with a selection the table replaces it in the copy, so the page draws the
/// copy's caret, in the table, and no selection: the document's selection names text that has
/// moved in the preview's layout.
#[test]
fn the_preview_draws_the_copy_caret_and_selection() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
app.session.sel = wordcraft_engine::Selection { anchor: Pos::body(0, 3), focus: Pos::body(1, 2) };
app.canvas.table_preview = Some((2, 2));
let layout = shown_layout(&mut app);
let sel = preview_sel(&app).expect("the preview's selection").clone();
assert!(sel.is_collapsed(), "the table took the selection's place");
assert_ne!(sel, app.session.sel);
let c = layout.caret_on(&sel.focus, 0).expect("the caret is on the preview's page");
assert!(layout.pages[c.page].items.iter().any(|it| matches!(it, Placed::Lines { path, .. } if path.0.len() == 4 && *path == sel.focus.path)));
assert!(sel.focus.path.cell().is_some(), "the caret is in the table");
assert_eq!(shown_layout(&mut app).index, app.session.layout().index);
assert!(preview_sel(&app).is_none(), "no preview: the document's own selection");
}
/// #3931 review: the preview is laid out again when the caret moves or the view changes with
/// the grid still hovered, so the page shows the table where a click would put it, at the
/// view's width.
#[test]
fn the_preview_follows_the_caret_and_the_view() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
let tables =
|l: &DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).map(|(_, p)| p.0[0]).collect::<HashSet<u32>>();
app.session.run("caret.docEnd", &json!({})).unwrap();
app.canvas.table_preview = Some((2, 2));
let at_end = tables(&shown_layout(&mut app));
app.session.run("caret.docStart", &json!({})).unwrap();
app.canvas.table_preview = Some((2, 2));
let at_start = tables(&shown_layout(&mut app));
assert_ne!(at_start, at_end, "the caret moved: so did the table");
let (_, _, fresh) = app.session.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
assert_eq!(at_start, tables(&fresh));
app.session.view.mode = wordcraft_layout::ViewMode::Web;
app.session.view.web_width = 300.0;
app.canvas.table_preview = Some((2, 2));
let web = shown_layout(&mut app);
assert_eq!(web.pages[0].w, app.session.layout().pages[0].w, "laid out at the Web view's width");
}
/// A window-following layout size (#1876, Read Mode): applied at once on entry or when forced,
/// held while the window is dragged, applied once the size has been still for the settle time.
#[test]
+54 -2
View File
@@ -326,9 +326,61 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
let _ = (&png, path);
#[cfg(not(target_arch = "wasm32"))]
{
if let Err(e) = std::fs::write(path, &png) {
return json!({"ok": false, "error": format!("{path}: {e}")});
if !path.to_ascii_lowercase().ends_with(".png") {
return json!({"ok": false, "error": format!("{path}: a screenshot path must end in .png")});
}
if let Err(e) = wordcraft_engine::io::write_file(path, &png) {
return json!({"ok": false, "error": e});
}
}
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
}
#[cfg(test)]
mod screenshot_tests {
use super::save_screenshot;
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn screenshot_path_must_be_png_and_does_not_follow_symlinks() {
let dir = std::env::temp_dir().join(format!("wc-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _cleanup = Cleanup(dir.clone());
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
let txt = dir.join("shot.txt");
let r = save_screenshot(&img, txt.to_str());
assert_eq!(r["ok"], false);
assert!(!txt.exists());
let png = dir.join("shot.png");
let r = save_screenshot(&img, png.to_str());
assert_eq!(r["ok"], true);
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
#[cfg(unix)]
{
let victim = dir.join("victim.txt");
std::fs::write(&victim, "keep").unwrap();
let link = dir.join("link.png");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let _ = save_screenshot(&img, link.to_str());
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
}
let other = dir.join("other");
std::fs::create_dir_all(&other).unwrap();
let roots = wordcraft_engine::file_access::AutomationRoots::new(None, Some(&other)).unwrap();
let outside = dir.join("outside.png");
let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str()));
assert_eq!(r["ok"], false);
assert!(!outside.exists());
}
}
+98 -30
View File
@@ -315,6 +315,8 @@ pub enum Dialog {
offset: f32,
/// The selection already has a phonetic guide (Remove is offered).
existing: bool,
/// Ask the Ruby text box for the keyboard on the first frame (once, so Tab and clicks still move it).
focus: bool,
},
NewStyle {
name: String,
@@ -1208,6 +1210,7 @@ impl Dialog {
size: f("size"),
offset: f("offset"),
existing: v.get("existing").and_then(Value::as_bool).unwrap_or(false),
focus: true,
}
}
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
@@ -1250,7 +1253,7 @@ impl Dialog {
filter: RecipientFilter::from_session(m),
}
} else {
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect();
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(ToString::to_string).collect();
let rows = vec![vec![String::new(); fields.len()]];
Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() }
}
@@ -1353,7 +1356,7 @@ impl Dialog {
}
}
/// Grid picker for Insert › Table (hover to size, click to insert).
/// Grid picker for Insert › Table (hover to size and preview on the page, click to insert).
pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
let t = Tokens::get(ui.ctx());
let id = egui::Id::new("table_picker_hover");
@@ -1388,9 +1391,16 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
}
});
ui.data_mut(|d| d.insert_temp(id, new_hover));
if new_hover != hover {
// The page may already be drawn this frame (Focus drops the ribbon over it afterwards).
ui.ctx().request_repaint();
}
if let Some((r, c)) = clicked {
let _ = app.run("insert.table", json!({"rows": r, "cols": c}));
ui.close();
} else if new_hover.0 > 0 {
// Word's Live Preview: the page shows the hovered table until a square is clicked.
app.canvas.table_preview = Some(new_hover);
}
}
@@ -1935,14 +1945,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
mixed,
..
} => {
ui.horizontal(|ui| {
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
*tab = k as u8;
}
}
});
ui.separator();
let names = ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].map(|n| tl!(n).to_string());
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = k as u8;
}
ui.add_space(6.0);
match *tab {
0 => {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
@@ -2040,15 +2047,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form),
Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form),
Dialog::Find { query, replace, match_case, whole_word, regex, extra, tab, goto_what, goto_target, message, focus } => {
ui.horizontal(|ui| {
for (t, label) in [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")] {
if ui.selectable_label(*tab == t, tl!(label)).clicked() && *tab != t {
*tab = t;
message.clear();
}
}
});
ui.separator();
const FIND_TABS: [(FindTab, &str); 3] = [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")];
let names = FIND_TABS.map(|(_, l)| tl!(l).to_string());
let current = FIND_TABS.iter().position(|(t, _)| t == tab).unwrap_or(0);
if let Some((t, _)) = crate::widgets::dialog_tabs(ui, &names, current).and_then(|k| FIND_TABS.get(k)) {
*tab = *t;
message.clear();
}
ui.add_space(6.0);
if *tab == FindTab::GoTo {
return goto_body(app, ui, goto_what, goto_target, message);
}
@@ -2491,7 +2497,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::Phonetic { base, ruby, align, size, offset, existing } => {
Dialog::Phonetic { base, ruby, align, size, offset, existing, focus } => {
// Base and ruby text are usually Chinese or Japanese: fonts for them load from the next frame.
app.want_system_cjk = true;
const ALIGNS: [(&str, &str); 6] = [
@@ -2507,7 +2513,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.label(egui::RichText::new(base.as_str()).strong());
ui.end_row();
ui.label(tl!("Ruby text:"));
ui.text_edit_singleline(ruby);
let r = ui.text_edit_singleline(ruby);
// Typing goes to the Ruby text box, not the selected text in the document (#3914).
if std::mem::take(focus) {
r.request_focus();
}
ui.end_row();
ui.label(tl!("Alignment:"));
let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l);
@@ -2699,15 +2709,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::About { tab } => {
ui.set_width(660.0);
ui.horizontal(|ui| {
for (i, l) in ["About", "Contributors", "Models"].into_iter().enumerate() {
let i = i as u8;
if ui.selectable_label(*tab == i, tl!(l)).clicked() {
*tab = i;
}
}
});
ui.separator();
let names = ["About", "Contributors", "Models"].map(|n| tl!(n).to_string());
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = i as u8;
}
ui.add_space(6.0);
match *tab {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
@@ -4107,6 +4113,44 @@ mod tests {
use wordcraft_doc::props::Align;
use wordcraft_engine::Session;
/// #3931: hovering Insert › Table's grid asks the page for a preview of that table (rows by
/// columns) and changes nothing; leaving the grid drops it; a click inserts the table with no
/// preview left over for the page to show on top of it.
#[test]
fn the_table_grid_previews_the_hovered_table_until_the_click() {
use egui_kittest::kittest::Queryable;
let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default());
let mut h =
egui_kittest::Harness::builder().with_size(vec2(400.0, 400.0)).build_ui_state(|ui, app: &mut WordApp| table_grid_picker(ui, app), a);
h.run_steps(2);
let spacing = h.ctx.global_style().spacing.clone();
let top = h.get_by_label("Insert Table").rect();
// Squares are 16 points with 2 between, in grid columns at least `interact_size` wide:
// the middle of row 3, column 4.
let col = spacing.interact_size.x.max(16.0) + 2.0;
let at = egui::pos2(top.left() + 3.0 * col + 8.0, top.bottom() + spacing.item_spacing.y + 2.0 * 18.0 + 8.0);
h.event(egui::Event::PointerMoved(at));
h.run_steps(2);
assert_eq!(h.state().canvas.table_preview, Some((3, 4)), "three rows of four columns");
assert!(h.query_by_label("4×3 Table").is_some(), "the label reads columns × rows");
assert!(h.state().session.doc.body.iter().all(|b| b.as_table().is_none()) && !h.state().session.can_undo());
h.event(egui::Event::PointerMoved(egui::pos2(390.0, 390.0)));
h.state_mut().canvas.table_preview = None;
h.run_steps(2);
assert_eq!(h.state().canvas.table_preview, None, "off the grid nothing is previewed");
h.event(egui::Event::PointerMoved(at));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
// The page takes the request every frame; here nothing does.
h.state_mut().canvas.table_preview = None;
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
let t = h.state().session.doc.body.iter().find_map(|b| b.as_table()).expect("the click inserts the table");
assert_eq!((t.rows.len(), t.cols()), (3, 4));
assert_eq!(h.state().canvas.table_preview, None, "the inserted table isn't previewed again");
}
/// Go To's page box (#712): a number, +n / -n from the current page (a typographic minus
/// too), empty for the next page; anything else is refused.
#[test]
@@ -4311,6 +4355,30 @@ mod tests {
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), before, "search does not change the document");
}
/// #3914: the Phonetic Guide dialog opens with the keyboard in its Ruby text box, so typing
/// fills the box and does not replace the selected text in the document.
#[test]
fn phonetic_guide_ruby_box_takes_the_keyboard_on_open() {
let mut app = app_with("Unchanged document");
app.dialog = Some(Dialog::Phonetic {
base: "Unchanged".into(),
ruby: String::new(),
align: "center".into(),
size: 12.0,
offset: 0.0,
existing: false,
focus: true,
});
let mut h = app_harness(app);
h.run_steps(4);
h.event(egui::Event::Text("ab".into()));
h.run_steps(2);
match h.state().dialog.as_ref() {
Some(Dialog::Phonetic { ruby, .. }) => assert_eq!(ruby, "ab", "the typed text reaches the Ruby text box"),
_ => panic!("the Phonetic Guide dialog is still open"),
}
}
/// #2986: a box that keeps the keyboard asks for focus only while it lacks it. `request_focus`
/// always interrupts IME; on X11 that recreates the XIM context, and Search asking every frame
/// left fcitx taking no keys at all. Once the box has focus, no frame interrupts, and typing lands;
+2 -2
View File
@@ -242,7 +242,7 @@ pub struct CaptionForm {
impl CaptionForm {
pub fn read(app: &WordApp) -> CaptionForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
for l in &app.ui.caption_labels {
if !labels.contains(l) {
labels.push(l.clone());
@@ -937,7 +937,7 @@ pub struct FiguresForm {
impl FiguresForm {
pub fn read(app: &WordApp) -> FiguresForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
for l in app.ui.caption_labels.iter().cloned().chain(wordcraft_engine::cmd::citations::caption_labels(&app.session)) {
if !labels.contains(&l) {
labels.push(l);
+3 -2
View File
@@ -47,7 +47,8 @@ pub(crate) struct Listing {
/// Whether `path` has one of `exts` (case-insensitive); `*` takes any file.
pub(crate) fn matches(path: &Path, exts: &[String]) -> bool {
exts.iter().any(|e| e == "*") || path.extension().and_then(|e| e.to_str()).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
exts.iter().any(|e| e == "*")
|| path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// The subfolders and the files with one of `exts` in `dir`, at most `cap` of them, folders first
@@ -151,7 +152,7 @@ impl FileBrowser {
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
}
FileDialogRequest::Save { name, .. } => {
let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty());
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty());
let mut f: Vec<(String, Vec<String>)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect();
f.push(("All files".into(), vec!["*".into()]));
(name.clone(), f)
+3 -3
View File
@@ -434,10 +434,10 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
v.extend(own);
let words: Vec<&str> = ctl.tip.split_whitespace().filter(|w| w.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())).collect();
if let Some(w) = words.first() {
v.extend(w.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()));
v.extend(w.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
}
if words.len() > 1 {
v.extend(words.iter().map(|w| w.chars().next().unwrap_or(' ')).filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()));
v.extend(words.iter().map(|w| w.chars().next().unwrap_or(' ')).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
}
v
};
@@ -486,7 +486,7 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
}
}
}
out.into_iter().map(|o| o.unwrap_or_default()).collect()
out.into_iter().map(Option::unwrap_or_default).collect()
}
/// Pure letter assignment `(tab letters, per-tab control letters)`, for tests without a window.
+2 -2
View File
@@ -2568,7 +2568,7 @@ impl WordApp {
/// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients.
fn drain_inbox(&mut self) {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let mut placed_picture = false;
for (name, bytes) in files {
let lower = name.to_ascii_lowercase();
@@ -2602,7 +2602,7 @@ impl WordApp {
/// otherwise so the focused field's own paste applies (a text field ignores a picture).
fn drain_pasted(&mut self) {
let Some(pasted) = self.services.pasted.clone() else { return };
let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(|e| e.into_inner()));
let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let target = paste_target(self.canvas.focused, self.ui.backstage, self.dialog.is_some() || self.file_dialog_open());
for (_, bytes) in pictures {
if !target {
+1 -1
View File
@@ -57,7 +57,7 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u
}
fn is_picture_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Pasted text that is only paths or `file://` addresses of picture files — what file managers
+42
View File
@@ -1299,6 +1299,48 @@ pub fn icon_menu_item(ui: &mut Ui, icon: &str, text: &str, shortcut: &str, min_w
resp
}
/// A strip of dialog tabs. The active tab joins the page below it.
/// Returns the index of the tab the user clicked.
pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option<usize> {
const H: f32 = 26.0;
let t = Tokens::get(ui.ctx());
let page = ui.visuals().window_fill;
let need: f32 = labels.iter().map(|l| ui.painter().layout_no_wrap(l.clone(), regular(12.5), t.text).size().x + 27.0).sum::<f32>() + 1.0;
ui.set_min_width(need);
let origin = ui.cursor().min;
let width = ui.available_width();
let base_y = origin.y + H;
let base = ui.painter().add(egui::Shape::Noop);
let top = CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 };
let mut x = origin.x;
let mut clicked = None;
for (i, label) in labels.iter().enumerate() {
let on = i == selected;
let galley = ui.painter().layout_no_wrap(label.clone(), regular(12.5), t.text);
let w = galley.size().x + 28.0;
let r = Rect::from_min_max(pos2(x, origin.y + 2.0), pos2(x + w, base_y));
let resp = ui.interact(r, ui.id().with(("dialog_tab", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label));
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
let fill = if on { page } else { page.lerp_to_gamma(t.border, 0.14 + 0.1 * hover) };
let p = ui.painter();
let shown = if on { r } else { Rect::from_min_max(pos2(r.min.x, r.min.y + 2.0), r.max) };
p.rect(shown, top, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
if on {
p.rect_filled(Rect::from_min_max(pos2(r.min.x + 1.0, base_y - 1.0), pos2(r.max.x - 1.0, base_y + 1.0)), 0.0, page);
}
let color = if on { t.text } else { t.text.gamma_multiply(0.8) };
p.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, color);
if resp.clicked() && !on {
clicked = Some(i);
}
x += w - 1.0;
}
ui.allocate_space(vec2(width, H + 1.0));
ui.painter().set(base, egui::Shape::line_segment([pos2(origin.x, base_y), pos2(origin.x + width, base_y)], Stroke::new(1.0, t.border)));
clicked
}
#[cfg(test)]
mod tests {
use super::*;
+2 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -19,6 +19,7 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
return $?
}
VERSION="${WORDCRAFT_VERSION:-$(workspace_version)}"
@@ -42,6 +43,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
return 0
}
# Copy licence and readme files that exist into a package directory.
@@ -51,6 +53,7 @@ copy_docs() {
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
return 0
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
@@ -65,5 +68,7 @@ copy_font_licences() {
# Portable SHA-256 of a file (prints just the hash).
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
local file="$1"
if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
return $?
}
+5 -1
View File
@@ -21,7 +21,11 @@ MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render() {
local src="$1" size="$2" dst="$3"
resvg -w "$size" -h "$size" "$src" "$dst" </dev/null
return 0
}
render "$SVG" 1024 "$DIR/wordcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256.
+9 -7
View File
@@ -75,7 +75,7 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
has() { local item="$1"; case " $FORMATS " in *" $item "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -119,13 +119,15 @@ if has appimage; then
fi
# Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run.
fetch_pinned() {
if [[ ! -x "$3" ]]; then
curl --proto '=https' -fsSL -o "$3.download" "$1"
printf '%s %s\n' "$2" "$3.download" | sha256sum -c -
chmod 755 "$3.download"
mv "$3.download" "$3"
local url="$1" expected_sha="$2" target="$3"
if [[ ! -x "$target" ]]; then
curl --proto '=https' -fsSL -o "$target.download" "$url"
printf '%s %s\n' "$expected_sha" "$target.download" | sha256sum -c -
chmod 755 "$target.download"
mv "$target.download" "$target"
fi
printf '%s %s\n' "$2" "$3" | sha256sum -c -
printf '%s %s\n' "$expected_sha" "$target" | sha256sum -c -
return 0
}
APPDIR="$WORK/WordCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
+4 -2
View File
@@ -143,18 +143,20 @@ rm -f "$DMG"
# Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with
# "Resource busy". Usage: hdiutil_retry <verb> <args...>; five tries with growing pauses.
hdiutil_retry() {
local verb="$1"
local attempt
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
if [[ "$attempt" = 5 ]]; then
echo "error: hdiutil $1 failed 5 times" >&2
echo "error: hdiutil $verb failed 5 times" >&2
return 1
fi
warn "hdiutil $1 failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
warn "hdiutil $verb failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
return 1
}
# `hdiutil create -srcfolder` copies the staged files as they are. The previous
# `makehybrid -hfs` + `convert` gave every file and folder in the image a com.apple.FinderInfo
+1 -1
View File
@@ -14,7 +14,7 @@ const ASSET_EXT: &[&str] = &[
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"];
pub fn is_asset(path: &str) -> bool {
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
}