Compare commits

...
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 5a4c45e79a About: let every credits column be dragged and refit changed tables (#5588)
* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:16 +01:00
Dc-xp 6f552abf8e chore: follow craft-libs c5a1cbf for craft-pdf and krilla (#5281)
Same rev the other editors are on; cargo check -p wordcraft-ui-egui is clean.
2026-11-05 02:10:16 +01:00
Dennis Klappe 5f87d7461b packaging: RPM installs on openSUSE (library sonames, no shared directories) (#5369) 2026-11-05 02:10:10 +01:00
Rahulandrahul05ranjan 6a50c1e374 #5289: a section's page start flushes the page before with its own margins and separators (#5291)
The page the section before ends on is finished with its own margins and
footnote separators before the starting section's replace them, so the
footnotes pending on it are placed where the page they sit on says.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-05 02:10:03 +01:00
entropybeaterandBrian 4123bdc09f docx: keep a paragraph's text alignment (#5498)
* docx: keep a paragraph's text alignment

w:textAlignment (ECMA-376 17.3.1.39) was not read, so a save dropped it. Word sizes a line by it when a
list's bullet is larger than the text: without it, those lines grow and every page after them moves.
ParaProps gains text_alignment, read and carried through styles, and written back after w:jc. Kept for
the file only; the layout does not use it yet.

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 02:09:55 +01:00
Harrison BoothandClaude Opus 5.5 b3da7a4d36 Object drag preview: clamp the texture to the GPU limit (#4578)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:08:58 +01:00
Valentin KleinandClaude Sonnet 5.5 5e4c92bf20 contributors: set names for vangel76 (#5520)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:01:19 +01:00
.CooCoo 61a1808f0f Docs: spelling skips addresses after narrow no-break and figure spaces (#5528)
Spelling as you type now notes that a web or e-mail address after a
no-break, narrow no-break (U+202F), figure (U+2007) or ideographic space
is not marked while Ignore web and file addresses is on, as
address_after_a_wide_space_is_an_internet_token in craft-proof checks.
2026-11-05 02:01:13 +01:00
Joseph AngladaandClaude Sonnet 5.5 208facfb92 SmartArt: custom colours per item from the Text Pane (#3123)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:47:56 +01:00
entropybeaterandBrian 811a847e86 doc, docx: keep the compound border lines (#5499)
* doc, docx: keep the compound border lines

The nine thin and thick compound lines (thinThickSmallGap to thinThickThinLargeGap, ECMA-376 17.18.2)
were read as a single line and written back as one, on paragraphs, tables, cells and pages. BorderStyle
gains them; they are written back by name and drawn as a single line of the border's width, as before.
RTF export and import carry them too (the importer read four of them as double).

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 01:40:16 +01:00
Valentin KleinandClaude Sonnet 5.5 c9ecb1a14b Metafiles: read the picture size from the header instead of parsing the whole file (#5481) (#5493)
metafile_size() parsed the whole metafile, decoding every bitmap and building every path, to
read two numbers at picture insert and replace. A new wordcraft_metafile::size reads the frame
from the EMF header or the placeable WMF header, and falls back to the full parse for every
other file (plain WMF, an EMF without a usable frame, a file with no records), so it returns
what parse would. For an EMF with one 8000x5000 1 bpp bitmap the size now costs well under a
microsecond instead of about 280 ms.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:30 +01:00
Valentin KleinandClaude Sonnet 5.5 c38cd8c407 Metafiles: decode DIB bitmaps with the bit depth chosen once per bitmap (#5479) (#5492)
decode_clip matched the bit depth, bounds-checked and (for 16 and 32 bpp) divided per channel
for every pixel. The depth, palette and masks are now turned into a row decoder once per
bitmap: a 256-entry colour table for 1/4/8 bpp, plain channel swaps for 24 bpp and default
32 bpp, and precomputed shifts and scale tables for masked 16/32 bpp. Output is byte for byte
the same; the old per-pixel code stays in the tests as the reference.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:25 +01:00
Valentin KleinandClaude Sonnet 5.5 991a21dee6 Metafiles: refuse a bitmap whose side no renderer draws before decoding it (#5480) (#5490)
decode_clip only capped width x height at 40 MP. A 32 x 1,250,000 pixel 1 bpp bitmap (about
5 MB in the file) was decoded to 160 MB of RGBA and kept, then dropped by Item::take_pixels
because its side is over MAX_BITMAP_SIDE, while counting against the parse's 256 MB budget.
decode_clip now returns None when the clipped width or height is over the cap, so crops of a
large bitmap that fit still decode.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:20 +01:00
Hamza Naveed cbe6a9d5f7 Pin that a comment id past u32 is refused, not truncated (#3677) (#5273)
* Pin that a comment id past u32 is refused, not truncated (#3677)

`validate_comment_id` refuses an `id` that does not fit a `u32`, so the `as
u32` casts behind it can no longer reach another comment -- but nothing pinned
it, and the casts are still there to be reached if the guard moves.

The ids the test sends are the ones that would alias onto real comments if the
truncation came back: 2^32 truncates to 0 (and the first comment is 0), 2^32 +
id to that comment, 4294967301 to 5 as the report describes, and u64::MAX to
u32::MAX. Each of the four commands that take an id is tried with each, then
the comments are checked to be unchanged and unresolved, and a valid id is
accepted afterwards so the guard is shown to be a range check rather than a
refusal.

* Comment ids: the values past u32 go in the existing invalid-id test, refused by the range check
2026-11-05 01:32:15 +01:00
.CooCoo c419985c54 Tests: the balloon anchor test counts work steps instead of timing (#5608)
anchors_walk_dense_unrelated_markers_in_linear_work compared CPU times of
a few microseconds, so cache and timer noise could push the ratio past its
24x limit on a linear walk (43.52x in a CI run).

wordcraft-doc now counts, in tests only, the characters its paragraph
object lookups walk (objects_before, object_offsets and
objects_with_offsets), through a work::count helper behind the existing
test-support feature. The test asserts that 8x the markers take at most
12x the steps, as the layout linear-time tests do: the walk takes 2001 ->
16001 steps (8.0x), and the object_offsets + object_at walk it replaced
takes 2003001 -> 128024001 (63.9x).
2026-11-05 01:32:10 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 1c7b5013fa Drop Cap follow-ups: whole selection, script fonts, margin gap, menu keeps options (#4077)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:26:57 +01:00
50 changed files with 1752 additions and 144 deletions
Generated
+2 -2
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
+2 -2
View File
@@ -67,7 +67,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+6 -1
View File
@@ -35,7 +35,9 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -187,3 +189,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
+3 -2
View File
@@ -10,8 +10,9 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
test-support = []
[dependencies]
+1
View File
@@ -49,6 +49,7 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
+3 -1
View File
@@ -26,7 +26,9 @@ pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
+69 -1
View File
@@ -26,6 +26,40 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -107,6 +141,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +167,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +188,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -368,6 +432,10 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -388,7 +456,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+28
View File
@@ -0,0 +1,28 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
#[inline(always)]
pub(crate) fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+3 -2
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -343,6 +343,7 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
+1 -1
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in &mut first.runs {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+3
View File
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
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 = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+14
View File
@@ -4514,6 +4514,20 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+2
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -32,6 +33,7 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
+2 -1
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -221,6 +221,7 @@ fn every_para_prop_round_trips() {
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
text_alignment: Some(TextAlignment::Top),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
+64
View File
@@ -0,0 +1,64 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
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 = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+39 -8
View File
@@ -241,12 +241,16 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// 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.
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
pub(super) 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());
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -254,6 +258,21 @@ pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
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.
@@ -261,15 +280,27 @@ pub(super) 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_given = lines.is_some();
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>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { 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);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
})
}
+33 -5
View File
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
})
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
@@ -124,14 +124,36 @@ pub fn specs() -> Vec<CommandSpec> {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
g.clear_custom_colors();
Ok(())
})
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
edit(s, move |g| {
let i = item_at(g, i)?;
if g.style_accent(i).is_none() {
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
}
if let Some(it) = g.items.get_mut(i) {
it.color = color;
}
Ok(())
})
})
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
edit(s, |g| {
g.colors = SmartArtColors::default();
g.clear_custom_colors();
Ok(())
})
})
@@ -235,7 +257,13 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
Some(other) => clean_text(&other.to_string()),
};
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
item.color = match it.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
Ok(item)
}
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
})
+123
View File
@@ -9659,6 +9659,47 @@ fn smart_art_edits_undo_one_step_each() {
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
}
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
#[test]
fn smart_art_item_colors_stay_until_a_style_is_picked() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
run(&mut s, "smartArt.addShape", json!({"after": 0}));
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
}
/// A malformed colour must not silently replace a coloured item with its style colour.
#[test]
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
for command in ["insert.smartArt", "smartArt.items"] {
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
}
/// #497: hostile SmartArt params are capped or refused, never a panic.
#[test]
fn smart_art_hostile_params_are_capped() {
@@ -9680,6 +9721,7 @@ fn smart_art_hostile_params_are_capped() {
"smartArt.demote",
"smartArt.layout",
"smartArt.colors",
"smartArt.itemColor",
"smartArt.reset",
"smartArt.textPane",
] {
@@ -11636,6 +11678,87 @@ fn drop_cap_options_set_position_lines_distance_and_font() {
run(&mut s, "text.insert", json!({"text": "Cell"}));
assert!(s.sel.focus.path.cell().is_some());
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
// A selection is as good as its worst paragraph: an empty one or one starting with a space
// refuses a drop cap for all (the button greys out); None works while there is one to take away.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "First"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Third"}));
assert_eq!(s.doc.body.len(), 3);
run(&mut s, "select.all", json!({}));
assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed");
assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one");
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped"}));
// From the drop cap up into the empty paragraph (the caret there): None takes it away, the
// other positions are refused, as the enabled check says.
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}}));
assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away");
assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one");
assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away");
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": " first"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
run(&mut s, "select.all", json!({}));
assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection");
}
/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each
/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults,
/// and it is one undo step. Without `keep` the options not given are reset, as before.
#[test]
fn drop_cap_keep_leaves_each_paragraphs_options() {
let caps = |s: &Session| {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone()))
.collect::<Vec<_>>()
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First"}));
for text in ["Second", "Third"] {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": text}));
}
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0}));
let before = caps(&s);
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true}));
assert_eq!(
caps(&s),
vec![
(Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())),
(Some(2), Some(true), Some(3.0), None),
(Some(3), Some(true), None, None),
],
"each keeps its own options; the one without gets the defaults"
);
run(&mut s, "edit.undo", json!({}));
assert_eq!(caps(&s), before, "one undo step");
// Options given explicitly win over the kept ones; a junk `keep` is false.
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4}));
assert_eq!(caps(&s).iter().map(|c| c.0).collect::<Vec<_>>(), vec![Some(4); 3]);
assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept");
// A blank font given with `keep` clears the kept font (the text's own again).
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "}));
assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one");
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"}));
assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given");
// None ignores keep.
run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true}));
assert_eq!(caps(&s), vec![(None, None, None, None); 3]);
}
#[test]
+8 -1
View File
@@ -14,7 +14,9 @@ fn commented() -> Session {
}
fn invalid_ids_leave_state_untouched(command: &str) {
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64)] {
// Past `u32`: 2^32 truncates to comment 0 and 2^32 + 5 to 5, and `u64::MAX` is the far end.
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64), json!(4294967301u64), json!(u64::MAX)]
{
for at_comment in [true, false] {
let mut s = commented();
if !at_comment {
@@ -28,6 +30,11 @@ fn invalid_ids_leave_state_untouched(command: &str) {
let before = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty);
let result = s.run(command, &json!({"id": id, "text": "Replacement"}));
assert!(matches!(result, Err(CmdError::Params(_))), "{command}, id={id}, at_comment={at_comment}: {result:?}");
// Past `u32` it is the range check that refuses, not a lookup of whatever the id
// truncated to: 2^32 + 5 names no comment here, so "no such comment" would hide a cast.
if id.as_u64().is_some_and(|n| n > u64::from(u32::MAX)) {
assert!(format!("{result:?}").contains("32-bit"), "{command}, id={id}: {result:?}");
}
assert_eq!(
(s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty),
before,
+9
View File
@@ -153,6 +153,15 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
BorderStyle::Triple => "\\brdrtriple",
BorderStyle::DotDash => "\\brdrdashd",
BorderStyle::Wave => "\\brdrwavy",
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
}
}
+31
View File
@@ -1672,3 +1672,34 @@ fn rtf_backslash_space_and_special_breaks() {
let p = doc.body.first().unwrap().as_para().unwrap();
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
}
#[test]
fn compound_borders_round_trip() {
use wordcraft_doc::props::BorderStyle;
let lines = [
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
];
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
d.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| !p.plain_text().is_empty())
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
.collect()
};
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
let d = import(rtf.as_bytes()).unwrap();
assert_eq!(tops(&d), want);
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(tops(&back), want);
}
+12 -3
View File
@@ -206,15 +206,24 @@ impl Reader {
"clbrdrb" => self.cell_side = Some('b'),
"clbrdrr" => self.cell_side = Some('r'),
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
| "brdrtbl"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
if self.cell_side.is_some() || self.st.border_side.is_some() =>
{
use wordcraft_doc::props::{Border, BorderStyle};
let style = match w {
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
"brdrth" => BorderStyle::Thick,
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
"brdrdot" => BorderStyle::Dotted,
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
+3
View File
@@ -23,6 +23,9 @@ serde_json = { workspace = true }
image = { workspace = true }
log = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
js-sys = "0.3"
+7 -3
View File
@@ -54,9 +54,13 @@ fn anchors_walk_dense_unrelated_markers_in_linear_work() {
const N: usize = 2_000;
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
}
#[test]
+9 -1
View File
@@ -296,10 +296,18 @@ impl PageBuilder<'_> {
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
true
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
/// Finish the current page: the lines between its columns, its vertical alignment and its
/// footnotes, placed with the section state in effect (margins, gutter, separators).
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
pub(crate) fn finish_page(&mut self) {
self.close_column_lines();
self.apply_valign();
// The page is aligned once: finishing it again before it is left moves nothing twice.
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
self.flush_notes();
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
self.finish_page();
self.beneath = self.sect_beneath;
self.excl.clear();
// Floats placed above their anchors further down this page are known from its start.
+5 -2
View File
@@ -610,8 +610,11 @@ impl<'e> Breaker<'e> {
let short = lines.len() < dl as usize;
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
let lead = self.first_x0.unwrap_or(0.0) - self.first_left.max(rp.indent_left);
// In margin, the letter's right edge stays the distance away from the text's edge.
let dx = if rp.drop_cap_margin { lead.max(0.0) + pl.drop_cap_w + rp.drop_cap_distance } else { dw.min(lead) };
// In margin, the letter's right edge stays the distance left of the text column (or of a
// hanging first line), whatever the alignment or first-line indent: always in the margin.
let edge = self.first_left.min(rp.indent_left);
let in_margin = (self.first_x0.unwrap_or(0.0) - edge).max(0.0) + pl.drop_cap_w + rp.drop_cap_distance;
let dx = if rp.drop_cap_margin { in_margin } else { dw.min(lead) };
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
let dx = if self.rtl_para { pl.drop_cap_w - dx } else { dx };
if short {
+2
View File
@@ -266,6 +266,8 @@ impl<'a> Shaped<'a> {
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
if let Some(f) = &rp.drop_cap_font {
big.font = f.clone();
big.font_cs = f.clone();
big.font_ea = Some(f.clone());
}
let before = self.b.clusters.len();
self.b.shape(first, 0, &Arc::new(big), None);
+6
View File
@@ -509,6 +509,12 @@ pub(crate) fn layout_pass(
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
let sec = Sec { si, sect, start, tops };
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
// The page the section before ends on is finished with its own margins and footnote
// separators, before this section's replace them: the footnotes pending on it
// belong to the section whose page it is (#5289).
pb.finish_page();
}
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
if mode.web && si > 0 {
// one long page
+78
View File
@@ -2958,6 +2958,44 @@ fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// Section one ends its last page with a footnote pending; section two starts a new page
// with a wider left margin. The footnotes on the page before keep section one's margin,
// whichever way the new page starts (#5289).
let note_x = |start: SectionStart| {
let mut d = Document::from_text("Section one text.\nTwo");
let note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default());
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 5),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.margin_left = 144.0;
let l = lay(&d);
let x = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Part(n), x, .. } if *n == id => Some(*x),
_ => None,
})
.unwrap_or_else(|| panic!("the footnote is placed ({start:?})"));
(x, l.pages.len())
};
for start in [SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage] {
let (x, pages) = note_x(start);
assert!((x - 72.0).abs() < 0.01, "{start:?}: the footnote sits at section one's margin, not {x}");
assert!(pages >= 2, "{start:?}: the section starts a page");
}
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
@@ -4392,6 +4430,46 @@ fn drop_cap_options_place_the_letter() {
assert_ne!(text_style.as_deref(), Some("Liberation Mono"), "only the letter");
}
/// #2601: the letter's own font applies to complex-script and East Asian first letters too. In
/// margin, the letter sits in the left margin for any alignment or first-line indent: its right
/// edge is the distance left of the text column (of the first line's start, if that hangs further
/// left), never beside centred text or inside the column.
#[test]
fn drop_cap_font_covers_scripts_and_alignment_keeps_the_gap() {
use wordcraft_doc::props::Align;
let font = drop_cap_layout(|p| p.drop_cap_font = Some("Liberation Mono".into()));
let rc = font.clusters.first().and_then(|c| font.styles.get(c.style as usize)).map(|s| s.rc.clone());
let Some(rc) = rc else { panic!("no style") };
assert_eq!(rc.font_cs, "Liberation Mono");
assert_eq!(rc.font_ea.as_deref(), Some("Liberation Mono"));
// (alignment, left indent, first-line indent, the column's edge the letter hangs from)
let cases = [
(Align::Left, 0.0, 0.0, 0.0),
(Align::Center, 0.0, 0.0, 0.0),
(Align::Right, 0.0, 0.0, 0.0),
(Align::Left, 0.0, 36.0, 0.0),
(Align::Center, 18.0, 36.0, 18.0),
(Align::Left, 36.0, -36.0, 0.0),
];
for (align, left, first, edge) in cases {
let pl = drop_cap_layout(|p| {
p.align = Some(align);
p.indent_left = Some(left);
p.indent_first = Some(first);
p.drop_cap_margin = Some(true);
p.drop_cap_distance = Some(9.0);
});
let text_x = pl.lines.first().and_then(|l| l.xs.first().copied()).unwrap_or(f32::NAN);
let letter_right = text_x + pl.glyphs.first().map(|g| g.dx).unwrap_or(f32::NAN) + pl.drop_cap_w;
assert!(
(letter_right - (edge - 9.0)).abs() < 0.5,
"{align:?}, indents {left}/{first}: letter's right edge {letter_right}, expected {} (text at {text_x})",
edge - 9.0
);
}
}
#[test]
fn auto_hyphenation_breaks_long_words() {
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
+358 -43
View File
@@ -1,7 +1,7 @@
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
use crate::{MAX_PIXELS, bytes::Bytes};
use crate::{MAX_BITMAP_SIDE, MAX_PIXELS, bytes::Bytes};
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
@@ -88,6 +88,7 @@ pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
/// are opaque. Only the clipped rows and columns are decoded, so the work and the memory follow the
/// crop, not the whole bitmap; the whole bitmap is still validated (size cap, palette, bits length).
/// None as well when the clipped width or height is over [`MAX_BITMAP_SIDE`], which no renderer draws.
pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
@@ -98,55 +99,151 @@ pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
// The renderers draw no bitmap with a side over the cap (`Item::take_pixels`); decoding one
// would only fill the parse's memory budget with pixels nobody draws.
let cap = MAX_BITMAP_SIDE as usize;
if clip.x1 - clip.x0 > cap || clip.r1 - clip.r0 > cap {
return None;
}
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
#[cfg(test)]
DECODED.with(|d| d.set(d.get() + ow * oh));
// The depth, palette and masks are the same for every pixel: pick the row decoder once.
let kind = RowKind::new(&hd, pal, keep_alpha);
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
}
let row = data.get(src_row * stride..(src_row + 1) * stride)?;
kind.decode_row(row, clip.x0..clip.x1, line.as_chunks_mut::<4>().0)?;
}
Some((Rgba { w: ow, h: oh, px }, out))
}
#[cfg(test)]
thread_local! {
/// Pixels decoded on this thread, so the tests can check that the work follows the crop.
/// Pixels asked of the decoder on this thread, so the tests can check that the work follows the crop.
static DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
#[cfg(test)]
DECODED.with(|d| d.set(d.get() + 1));
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
/// One channel of a masked 16/32 bpp pixel, with the shift and scale worked out once per bitmap.
/// Channels of up to 8 bits (every common 16 bpp layout) go through a table of their values, so a
/// pixel costs no division.
#[derive(Clone)]
struct Channel {
mask: u32,
shift: u32,
max: u64,
table: [u8; 256],
}
impl Channel {
fn new(mask: u32) -> Channel {
let shift = if mask == 0 { 0 } else { mask.trailing_zeros() };
let max = u64::from(mask >> shift);
let mut table = [0u8; 256];
if mask != 0 && max <= 255 {
for (val, t) in table.iter_mut().enumerate().take(max as usize + 1) {
*t = ((val as u64 * 255 + max / 2) / max) as u8;
}
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
Channel { mask, shift, max, table }
}
/// The channel of `v` scaled to 8 bits; 0 when the mask is empty.
fn get(&self, v: u32) -> u8 {
if self.mask == 0 {
return 0;
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
let val = u64::from((v & self.mask) >> self.shift);
if self.max <= 255 {
return self.table[usize::from(val as u8)];
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
((val * 255 + self.max / 2) / self.max) as u8
}
}
/// How the pixels of one DIB turn into RGBA, decided once from its header.
enum RowKind {
/// 1, 4 or 8 bpp: `bpp` bits per index into a table of every possible index.
Indexed { bpp: usize, table: Box<[[u8; 4]; 256]> },
/// 24 bpp: BGR triples.
Bgr24,
/// 32 bpp with the default masks: BGRA quads, alpha kept or forced opaque.
Bgra32 { keep_alpha: bool },
/// 16 bpp, or 32 bpp with other masks.
Masked { bpp: usize, ch: Box<[Channel; 3]>, keep_alpha: bool },
}
impl RowKind {
fn new(hd: &Header, pal: &[u8], keep_alpha: bool) -> RowKind {
match hd.bpp {
1 | 4 | 8 => {
// An index past the palette reads as black, as in `palette`.
let mut table = Box::new([[0u8, 0, 0, 255]; 256]);
for (i, t) in table.iter_mut().enumerate().take(1 << hd.bpp) {
let [r, g, b] = palette(pal, i);
*t = [r, g, b, 255];
}
RowKind::Indexed { bpp: hd.bpp, table }
}
24 => RowKind::Bgr24,
32 if hd.masks == [0xFF_0000, 0xFF00, 0xFF] => RowKind::Bgra32 { keep_alpha },
_ => RowKind::Masked { bpp: hd.bpp, ch: Box::new(hd.masks.map(Channel::new)), keep_alpha },
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
/// Decodes the columns `cols` of one source row `row` (all of its bytes) into `out`. `None` when
/// the row is shorter than the columns need.
fn decode_row(&self, row: &[u8], cols: std::ops::Range<usize>, out: &mut [[u8; 4]]) -> Option<()> {
match self {
RowKind::Indexed { bpp: 8, table } => {
for (d, s) in out.iter_mut().zip(row.get(cols)?) {
*d = table[usize::from(*s)];
}
}
RowKind::Indexed { bpp: 4, table } => {
for (d, x) in out.iter_mut().zip(cols) {
let b = *row.get(x / 2)?;
*d = table[usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })];
}
}
RowKind::Indexed { table, .. } => {
for (d, x) in out.iter_mut().zip(cols) {
let b = *row.get(x / 8)?;
*d = table[usize::from((b >> (7 - x % 8)) & 1)];
}
}
RowKind::Bgr24 => {
let src = row.get(cols.start.checked_mul(3)?..cols.end.checked_mul(3)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<3>().0) {
*d = [s[2], s[1], s[0], 255];
}
}
RowKind::Bgra32 { keep_alpha } => {
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
*d = [s[2], s[1], s[0], if *keep_alpha { s[3] } else { 255 }];
}
}
RowKind::Masked { bpp: 16, ch, .. } => {
let [r, g, b] = &**ch;
let src = row.get(cols.start.checked_mul(2)?..cols.end.checked_mul(2)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<2>().0) {
let v = u32::from(u16::from_le_bytes(*s));
*d = [r.get(v), g.get(v), b.get(v), 255];
}
}
RowKind::Masked { ch, keep_alpha, .. } => {
let [r, g, b] = &**ch;
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
let v = u32::from_le_bytes(*s);
*d = [r.get(v), g.get(v), b.get(v), if *keep_alpha { (v >> 24) as u8 } else { 255 }];
}
}
}
};
Some([r, g, b, 255])
Some(())
}
}
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
@@ -154,17 +251,6 @@ fn palette(pal: &[u8], i: usize) -> [u8; 3] {
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// The columns `x0..x1` and top-down rows `r0..r1` of a bitmap that a clipped source rectangle covers.
struct Clip {
x0: usize,
@@ -343,6 +429,216 @@ mod tests {
(info, bits)
}
/// The per-pixel decoder `decode_clip` used before the depth was chosen once per bitmap. Kept as
/// the reference the fast decoder must match byte for byte.
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
DECODED.with(|d| d.set(d.get() + 1));
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
};
Some([r, g, b, 255])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// `decode_clip` as it was: the same clip, then `pixel` for every output pixel.
fn decode_clip_reference(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
if pixels == 0 || pixels > MAX_PIXELS {
return None;
}
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
}
}
Some((Rgba { w: ow, h: oh, px }, out))
}
/// A DIB with chosen header fields: `masks` makes it BI_BITFIELDS (inline after a 40 byte
/// header, or inside a larger header), `clr_used` limits the palette, and the palette, when the
/// depth is indexed, has `pal_entries` entries (fewer than the depth names reads as black).
fn make_dib_with(
w: usize,
h: i32,
bpp: usize,
masks: Option<([u32; 3], usize)>,
clr_used: u32,
pal_entries: usize,
seed: &mut u64,
) -> (Vec<u8>, Vec<u8>) {
let mut rnd = || {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*seed
};
let size = masks.map_or(40, |(_, s)| s);
let mut info = Vec::new();
info.extend((size as u32).to_le_bytes());
info.extend((w as i32).to_le_bytes());
info.extend(h.to_le_bytes());
info.extend(1u16.to_le_bytes());
info.extend((bpp as u16).to_le_bytes());
info.extend(if masks.is_some() { 3u32 } else { 0 }.to_le_bytes());
info.extend([0u8; 12]);
info.extend(clr_used.to_le_bytes());
info.extend([0u8; 4]);
info.resize(size.max(40), 0);
if let Some((m, _)) = masks {
for (i, v) in m.iter().enumerate() {
let at = 40 + i * 4;
if size == 40 {
info.extend(v.to_le_bytes());
} else if let Some(dst) = info.get_mut(at..at + 4) {
dst.copy_from_slice(&v.to_le_bytes());
}
}
}
for _ in 0..pal_entries * 4 {
info.push(rnd() as u8);
}
let stride = (w * bpp).div_ceil(32) * 4;
let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect();
(info, bits)
}
fn assert_same(info: &[u8], bits: &[u8], what: &str) {
for keep_alpha in [false, true] {
for src in [[0, 0, 0, 0], [3, 1, 20, 3], [-4, -4, 100, 100], [36, 4, 5, 5], [1, 0, 1, 5], [8, 2, 17, 2], [0, 3, 37, 1]] {
let dst = [1.0, 2.0, 30.0, 40.0];
let old = decode_clip_reference(Bytes(info), Bytes(bits), keep_alpha, src, dst);
let new = decode_clip(Bytes(info), Bytes(bits), keep_alpha, src, dst);
match (old, new) {
(None, None) => {}
(Some((a, ad)), Some((b, bd))) => {
assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{what} alpha={keep_alpha} {src:?}");
assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{what} alpha={keep_alpha} {src:?}");
}
_ => panic!("old and new disagree on None: {what} alpha={keep_alpha} {src:?}"),
}
}
}
}
#[test]
fn decode_clip_matches_the_per_pixel_reference_at_every_depth_palette_and_mask() {
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
// Widths that leave padding bits in the row, so a row's last byte is only partly used.
for w in [1, 9, 37] {
for h in [6, -6] {
for bpp in [1usize, 4, 8] {
let n = 1usize << bpp;
// Full palette, clrUsed shorter than the depth (the rest reads black), a
// palette cut short of what clrUsed names, and none at all.
for (clr_used, entries) in [(0, n), (3, 3), (n as u32 + 50, n), (0, 1), (0, 0)] {
let (info, bits) = make_dib_with(w, h, bpp, None, clr_used, entries, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp w={w} h={h} clrUsed={clr_used} palette={entries}"));
}
}
let (info, bits) = make_dib_with(w, h, 24, None, 0, 0, &mut seed);
assert_same(&info, &bits, &format!("24 bpp w={w} h={h}"));
// A colour table on a deep DIB is carried but unused.
let (info, bits) = make_dib_with(w, h, 24, None, 5, 5, &mut seed);
assert_same(&info, &bits, &format!("24 bpp with palette w={w} h={h}"));
// Masks on depths that ignore them still move the palette and the bits.
for bpp in [1usize, 8, 24] {
let n = if bpp == 24 { 0 } else { 1 << bpp };
let (info, bits) = make_dib_with(w, h, bpp, Some(([0xF800, 0x07E0, 0x001F], 40)), 0, n, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp with masks w={w} h={h}"));
}
let masks16: [Option<[u32; 3]>; 5] = [
None,
Some([0xF800, 0x07E0, 0x001F]),
Some([0x7C00, 0x03E0, 0x001F]),
Some([0x0F00, 0x00F0, 0x000F]),
Some([0xF800, 0x0000, 0x001F]),
];
let masks32: [Option<[u32; 3]>; 6] = [
None,
Some([0xFF_0000, 0xFF00, 0xFF]),
Some([0xFF, 0xFF00, 0xFF_0000]),
Some([0x3FF0_0000, 0x000F_FC00, 0x0000_03FF]),
Some([0xFFFF_FFFF, 0x0000_FF00, 0]),
Some([0x00FF_FF00, 0x00FF_FF00, 0x0000_00FF]),
];
for (bpp, sets) in [(16usize, masks16.as_slice()), (32, masks32.as_slice())] {
for m in sets {
// 40: the masks follow the header; 56 and 108: they are inside it.
for hsize in [40usize, 56, 108] {
let inline = m.map(|m| (m, hsize));
let (info, bits) = make_dib_with(w, h, bpp, inline, 0, 0, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp masks={m:x?} header={hsize} w={w} h={h}"));
}
}
}
}
}
}
/// Timing of the per-pixel reference against `decode_clip` on a 40 MP bitmap:
/// `cargo test -p wordcraft-metafile --release -- --ignored --nocapture dib_decode_speed`.
#[test]
#[ignore = "timing only"]
fn dib_decode_speed() {
let mut seed = 6u64;
for bpp in [1usize, 4, 8, 16, 24, 32] {
let (info, bits) = make_dib(8000, -5000, bpp, &mut seed);
for round in 0..4 {
let t = std::time::Instant::now();
let a = decode_clip_reference(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
let slow = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let b = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
let fast = t.elapsed().as_secs_f64() * 1e3;
assert!(a == b);
eprintln!("{bpp:2} bpp round {round}: reference {slow:4.0} ms, decode_clip {fast:4.0} ms");
}
}
}
#[test]
fn decode_clip_matches_decode_then_clip_blit() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
@@ -401,4 +697,23 @@ mod tests {
decode_whole(Bytes(&info), Bytes(&bits), false).unwrap();
assert_eq!(DECODED.with(|d| d.get()), 400 * 300);
}
#[test]
fn decode_clip_refuses_a_side_no_renderer_draws() {
let cap = MAX_BITMAP_SIDE as usize;
let mut seed = 11;
// 1 bpp, one row longer than the cap: 5 KB of bits, 120 KB of RGBA.
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
// The same for the height.
let (info, bits) = make_dib(1, cap as i32 + 1, 1, &mut seed);
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
// A crop of the wide bitmap within the cap still decodes, and so does the cap itself.
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 0, 100, 1], [0.0, 0.0, 1.0, 1.0]).unwrap();
assert_eq!((img.w, img.h), (100, 1));
let (info, bits) = make_dib(cap, 1, 1, &mut seed);
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).unwrap();
assert_eq!((img.w, img.h), (cap, 1));
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
}
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
fn frame_from_header(input: Bytes) -> Option<Frame> {
pub(crate) fn frame_from_header(input: Bytes) -> Option<Frame> {
let [fl, ft, fr, fb] = rect32(input, 24)?;
if fr <= fl || fb <= ft {
return None;
+2
View File
@@ -15,6 +15,7 @@ mod dib;
mod emf;
mod geom;
mod place;
mod size;
mod wmf;
#[cfg(test)]
@@ -24,6 +25,7 @@ use bytes::Bytes;
use canvas::{Raw, RawText};
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout, crop_window};
pub use size::size;
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
+98
View File
@@ -0,0 +1,98 @@
//! The size of a metafile picture without playing its records.
use crate::bytes::Bytes;
use crate::{Error, emf, f, parse, wmf};
/// A placeable WMF header is 22 bytes, followed by the 9-word standard header.
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const PLACEABLE_LEN: usize = 22;
const DEFAULT_INCH: f64 = 1440.0;
/// The size of a WMF or EMF picture in points (device or logical units when the file states no
/// size), the same as `parse(bytes)` would put in the picture's `width` and `height`, or the error
/// `parse` would give.
///
/// An EMF and a placeable WMF carry their frame in the header, so the size is read from there
/// without decoding a bitmap or building a path. Any other file (a plain WMF, an EMF without a
/// usable frame) takes the full parse.
pub fn size(bytes: &[u8]) -> Result<(f32, f32), Error> {
if let Some(size) = header_size(Bytes(bytes)) {
return Ok(size);
}
let pic = parse(bytes)?;
Ok((pic.width, pic.height))
}
/// The size when the header decides it and the file has a first record (otherwise `parse` fails
/// with `NoRecords`, so the header alone is not enough). None sends the caller to the full parse.
pub(crate) fn header_size(b: Bytes) -> Option<(f32, f32)> {
if emf::is_emf(b) {
return emf_size(b);
}
if wmf::is_wmf(b) && b.u32(0) == Some(PLACEABLE_KEY) {
return placeable_wmf_size(b);
}
None
}
/// `rclFrame` mapped to points. The frame must pass the checks `build` applies to it.
fn emf_size(b: Bytes) -> Option<(f32, f32)> {
let hsize = b.u32(4)? as usize;
b.slice(8, 16)?;
let frame = emf::frame_from_header(b)?;
if !frame.rect.iter().all(|v| v.is_finite()) || frame.rect[2] <= 0.0 || frame.rect[3] <= 0.0 {
return None;
}
// The records start after the header; the parse needs one that is not the end record.
let pos = hsize.max(88);
let (typ, len) = (b.u32(pos)?, b.u32(pos.checked_add(4)?)? as usize);
if len < 8 || !len.is_multiple_of(4) || b.slice(pos, len).is_none() || typ == 14 {
return None;
}
let (w, h) = frame.size?;
Some((f(w).unwrap_or(0.0), f(h).unwrap_or(0.0)))
}
/// The placeable header's size in points, for a file whose records leave the target mapping alone
/// (see [`sets_the_mapping`]); the full parse decides the others.
fn placeable_wmf_size(b: Bytes) -> Option<(f32, f32)> {
let [l, t, r, bt] = [6, 8, 10, 12].map(|o| b.i16(o).map(f64::from));
let (l, t, r, bt) = (l?, t?, r?, bt?);
let inch = b.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
let (w, h) = (r - l, bt - t);
if w <= 0.0 || h <= 0.0 {
return None;
}
let hsize = b.u16(PLACEABLE_LEN + 2)? as usize;
if hsize < 9 {
return None;
}
let pos = PLACEABLE_LEN.checked_add(hsize.checked_mul(2)?)?;
let (words, func) = (b.u32(pos)? as usize, b.u16(pos.checked_add(4)?)?);
let len = words.checked_mul(2).filter(|l| *l >= 6)?;
if func == 0 || b.slice(pos, len).is_none() {
return None;
}
if sets_the_mapping(b, pos) {
return None;
}
Some((f(w * 72.0 / inch)?, f(h * 72.0 / inch)?))
}
/// Whether a record from `pos` on is SetMapMode (0x0103) or SetViewportExt (0x020E). Those can change
/// the frame the full parse ends with (a zero-size viewport leaves none), so the header size is not
/// enough. The records are only walked, not played, with the same stops as `wmf::parse`.
fn sets_the_mapping(b: Bytes, mut pos: usize) -> bool {
while let (Some(words), Some(func)) = (b.u32(pos), pos.checked_add(4).and_then(|o| b.u16(o))) {
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { return false };
if func == 0 || b.slice(pos, len).is_none() {
return false;
}
if matches!(func, 0x0103 | 0x020E) {
return true;
}
let Some(next) = pos.checked_add(len) else { return false };
pos = next;
}
false
}
+117
View File
@@ -948,6 +948,123 @@ fn place_keeps_only_finite_bounded_bitmaps_with_area() {
assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0)));
}
/// `size` against the full parse: same numbers or same error, and whether the header decided it.
fn assert_size_matches_parse(file: &[u8], from_header: bool, what: &str) {
let full = parse(file).map(|p| (p.width, p.height));
assert_eq!(crate::size(file), full, "{what}");
assert_eq!(crate::size::header_size(crate::bytes::Bytes(file)).is_some(), from_header, "{what}: header path");
if let Some(s) = crate::size::header_size(crate::bytes::Bytes(file)) {
assert_eq!(Ok(s), full, "{what}: header size");
}
}
#[test]
fn size_from_the_header_equals_the_full_parse() {
let rect = || erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
let emfs = [
("emf with a frame", emf(&[rect()], [0, 0, 50, 25], [0, 0, 2500, 1250]), true),
("emf with a large frame", emf(&[rect()], [0, 0, 50, 25], [-90_000, 0, 900_000, 1_250_000]), true),
("emf with an empty frame", emf(&[rect()], [0, 0, 50, 25], [0; 4]), false),
("emf with no records", emf(&[], [0, 0, 50, 25], [0, 0, 2500, 1250]), false),
("emf with no frame and no bounds", emf(&[rect()], [0; 4], [0; 4]), false),
];
for (what, file, header) in &emfs {
assert_size_matches_parse(file, *header, what);
}
let draw = [wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))];
let wmfs = [
("placeable wmf", wmf(&draw, Some([0, 0, 2880, 1440])), true),
(
"placeable wmf with a window mapping",
wmf(
&[wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(100), h16(200)])), wrec(0x020E, &cat(vec![h16(50), h16(100)])), draw[0].clone()],
Some([0, 0, 200, 100]),
),
false,
),
(
"placeable wmf with a zero viewport",
wmf(&[wrec(0x0103, &h16(8)), wrec(0x020E, &cat(vec![h16(0), h16(0)])), draw[0].clone()], Some([0, 0, 200, 100])),
false,
),
("placeable wmf with a window only", wmf(&[wrec(0x020C, &cat(vec![h16(100), h16(200)])), draw[0].clone()], Some([0, 0, 200, 100])), true),
("placeable wmf with an empty box", wmf(&draw, Some([0, 0, 0, 0])), false),
("placeable wmf with no records", wmf(&[], Some([0, 0, 2880, 1440])), false),
("plain wmf", wmf(&draw, None), false),
];
for (what, file, header) in &wmfs {
assert_size_matches_parse(file, *header, what);
}
assert_size_matches_parse(b"not a metafile at all", false, "garbage");
assert_size_matches_parse(&[], false, "empty");
}
#[test]
fn size_of_a_truncated_or_mutated_file_equals_the_full_parse() {
let rect = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
let draw = wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]));
let files = [emf(&[rect], [0, 0, 50, 25], [0, 0, 2500, 1250]), wmf(&[draw], Some([0, 0, 2880, 1440]))];
let mut seed = 0x1234_5678_9ABC_DEF0u64;
for file in &files {
for len in 0..=file.len() {
let cut = &file[..len];
assert_eq!(crate::size(cut), parse(cut).map(|p| (p.width, p.height)), "cut at {len}");
}
for _ in 0..3000 {
let mut m = file.clone();
for _ in 0..3 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let at = (seed % m.len() as u64) as usize;
m[at] = (seed >> 32) as u8;
}
let full = parse(&m).map(|p| (p.width, p.height));
let fast = crate::size(&m);
assert_eq!(fast, full, "{m:?}");
}
}
}
/// Time of the full parse against the header-only size for an EMF with one 8000 x 5000, 1 bpp bitmap
/// (5 MB, decodes to 160 MB): `cargo test -p wordcraft-metafile --release -- --ignored --nocapture size_speed`.
#[test]
#[ignore = "timing only"]
fn size_speed() {
let (w, h) = (8000i32, 5000i32);
let mut payload = vec![0u8; 16];
payload.extend(cat(vec![
e32(0),
e32(0),
e32(0),
e32(0),
e32(w),
e32(h),
e32(80),
e32(48),
e32(128),
h32((w / 8 * h) as u32),
e32(0),
e32(0x00CC_0020),
e32(100),
e32(50),
]));
payload.extend(info_header(w, h, 1, 0));
payload.extend([0, 0, 0, 0, 255, 255, 255, 0]);
payload.extend((0..(w / 8 * h) as usize).map(|i| (i * 31) as u8));
let file = emf(&[erec(81, &payload)], [0, 0, 100, 50], [0, 0, 2500, 1250]);
for round in 0..4 {
let t = std::time::Instant::now();
let full = parse(&file).map(|p| (p.width, p.height));
let full_ms = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let fast = crate::size(&file);
let fast_ms = t.elapsed().as_secs_f64() * 1e6;
assert_eq!(full, fast);
eprintln!("round {round}: parse {full_ms:.1} ms, size {fast_ms:.1} us");
}
}
#[test]
fn take_pixels_returns_only_drawable_bitmaps() {
let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] };
+2 -2
View File
@@ -145,8 +145,8 @@ pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
if !wordcraft_metafile::is_metafile(bytes) {
return None;
}
let pic = wordcraft_metafile::parse(bytes).ok()?;
let (w, h) = (pic.width, pic.height);
// The frame comes from the header where the file has one, so no bitmap is decoded for it.
let (w, h) = wordcraft_metafile::size(bytes).ok()?;
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
+133 -16
View File
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
@@ -579,4 +584,116 @@ mod tests {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+15 -2
View File
@@ -764,8 +764,21 @@ fn draw_line(p: &egui::Painter, [a, b]: [egui::Pos2; 2], style: BorderStyle, wid
(0..=n.min(400)).map(|i| a + dir * (len * i as f32 / n as f32) + normal * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
}
BorderStyle::Single | BorderStyle::Thick => {
p.line_segment([a, b], if style == BorderStyle::Thick { Stroke::new((px * 1.5).min(6.0), color) } else { stroke });
BorderStyle::Thick => {
p.line_segment([a, b], Stroke::new((px * 1.5).min(6.0), color));
}
// A compound thin and thick line shows as one line of its width, as on the page.
BorderStyle::Single
| BorderStyle::ThinThickSmallGap
| BorderStyle::ThickThinSmallGap
| BorderStyle::ThinThickThinSmallGap
| BorderStyle::ThinThickMediumGap
| BorderStyle::ThickThinMediumGap
| BorderStyle::ThinThickThinMediumGap
| BorderStyle::ThinThickLargeGap
| BorderStyle::ThickThinLargeGap
| BorderStyle::ThinThickThinLargeGap => {
p.line_segment([a, b], stroke);
}
}
}
+125 -6
View File
@@ -12,6 +12,7 @@ use wordcraft_doc::smart_art::{MAX_ITEMS, SmartArtColors, SmartArtLayout, SmartA
use crate::WordApp;
use crate::theme::{Tokens, c32, regular, semibold};
use crate::widgets;
/// The picker's categories (`All` first), in the order shown.
pub const CATEGORIES: [&str; 8] = ["All", "List", "Process", "Cycle", "Hierarchy", "Relationship", "Matrix", "Pyramid"];
@@ -151,6 +152,21 @@ pub fn swatches(p: &Painter, r: Rect, colors: SmartArtColors, theme: &[Rgb]) {
}
}
/// Swatches in `r` of the colours the items of `g` have now: their own or the style's.
pub fn custom_swatches(p: &Painter, r: Rect, g: &SmartArtSpec, theme: &[Rgb]) {
let shown: Vec<Color32> = (0..g.items.len())
.filter_map(|i| match g.items.get(i)?.color {
Some(c) => Some(c32(c)),
None => Some(accent(theme, g.style_accent(i)?)),
})
.take(5)
.collect();
let w = r.width() / shown.len().max(1) as f32;
for (k, c) in shown.into_iter().enumerate() {
p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w - 2.0, r.height())), 2.0, c);
}
}
/// Height of the picker's three columns, points: three rows of 96 pt tiles and the gaps between them.
const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
@@ -262,8 +278,12 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
/// The `smartArt.items` params for `g` with item `i`'s text replaced by `text`.
pub fn items_with(g: &SmartArtSpec, i: usize, text: &str) -> Value {
let items: Vec<Value> =
g.items.iter().enumerate().map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level})).collect();
let items: Vec<Value> = g
.items
.iter()
.enumerate()
.map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level, "color": it.color.map(|c| c.hex())}))
.collect();
json!({"items": items})
}
@@ -275,7 +295,9 @@ pub fn items_without(g: &SmartArtSpec, i: usize) -> Value {
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(j, it)| json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }}))
.map(|(j, it)| {
json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }, "color": it.color.map(|c| c.hex())})
})
.collect();
json!({"items": items})
}
@@ -353,6 +375,49 @@ pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
SmartArtTypeResult::Changed
}
/// Side of an item's colour button, points.
const SWATCH: f32 = 18.0;
/// Id of item `i`'s colour button.
fn color_button_id(i: usize) -> egui::Id {
egui::Id::unique(("smart_art_color_button", i))
}
/// The button at the right of item `i`'s row: its shape's colour (the style's until changed).
/// A click offers the colour grid; the style's colour is offered back.
fn color_button(app: &mut WordApp, ui: &mut Ui, g: &SmartArtSpec, i: usize) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::hover());
let resp = ui.interact(rect, color_button_id(i), Sense::click());
let label = g
.items
.get(i)
.filter(|it| !it.text.is_empty())
.map_or_else(|| format!("{} ({})", tl!("Shape color"), i + 1), |it| format!("{}: {}", tl!("Shape color"), it.text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label.as_str()));
let Some(accent_n) = g.style_accent(i) else { return };
let theme = app.session.doc.settings.theme_colors.clone();
let own = g.items.get(i).and_then(|it| it.color);
let fill = own.map_or_else(|| accent(&theme, accent_n), c32);
ui.painter().rect_filled(rect, 3.0, fill);
let line = if resp.hovered() { t.accent } else { t.border_strong };
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, line), egui::StrokeKind::Inside);
let resp = resp.on_hover_text(tl!("Shape color"));
egui::Popup::menu(&resp).show(|ui| {
let mut pick = widgets::color_grid(ui, &theme).map(|hex| json!({"index": i, "color": hex}));
ui.add_space(2.0);
ui.separator();
if ui.add_enabled(own.is_some(), egui::Button::new(tl!("Style Color")).frame(false)).clicked() {
pick = Some(json!({"index": i}));
}
if let Some(params) = pick {
app.smart_art_typing = None;
let _ = app.run("smartArt.itemColor", params);
ui.close();
}
});
}
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the
/// focused item and Shift+Tab promotes it, as Word's Text Pane does.
@@ -372,9 +437,9 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new("•").color(t.text_dim));
let mut text = it.text.clone();
let item_name = format!("{} {}", tl!("Text Pane"), i + 1);
let r = ui
.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY))
.accessible_name(item_name);
let width = (ui.available_width() - SWATCH - ui.spacing().item_spacing.x).max(40.0);
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(width)).accessible_name(item_name);
color_button(app, ui, &g, i);
if want_focus == Some(i) {
r.request_focus();
}
@@ -438,6 +503,22 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
mod tests {
use super::*;
#[test]
fn color_buttons_expose_item_names_and_empty_row_indices() {
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[0].text = "Named".into();
spec.items[1].text.clear();
let mut h = egui_kittest::Harness::builder().build_ui(move |ui| {
color_button(&mut app, ui, &spec, 0);
color_button(&mut app, ui, &spec, 1);
});
h.run_steps(2);
h.get_by_label("Shape color: Named");
h.get_by_label("Shape color (2)");
}
#[test]
fn removing_an_item_lifts_the_items_under_it() {
let g = SmartArtSpec::sample(SmartArtLayout::Hierarchy);
@@ -447,6 +528,44 @@ mod tests {
assert_eq!(items_with(&g, 0, "Boss")["items"][0]["text"], "Boss");
}
/// The colour button opens a grid: a swatch sets the item's own colour and Style Color clears it.
#[test]
fn the_color_button_sets_and_clears_an_items_colour() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("insert.smartArt", json!({"layout": "process"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
let mut n = 0u32;
let mut frame = |ctx: &egui::Context, events: Vec<egui::Event>, a: &mut WordApp| {
n += 1;
let input = egui::RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| text_pane(a, ui)).drop_without_applying_deltas();
};
let click = |ctx: &egui::Context, pos: egui::Pos2, a: &mut WordApp, frame: &mut dyn FnMut(&egui::Context, Vec<egui::Event>, &mut WordApp)| {
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, vec![egui::Event::PointerMoved(pos), button(true)], a);
frame(ctx, vec![button(false)], a);
frame(ctx, vec![], a);
};
let colour =
|a: &WordApp| wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).and_then(|g| g.items.get(1).and_then(|i| i.color));
frame(&ctx, vec![], &mut a);
let button = ctx.read_response(color_button_id(1)).expect("item 1 has a colour button");
let popup = egui::Popup::default_response_id(&button);
click(&ctx, button.rect.center(), &mut a, &mut frame);
assert!(egui::Popup::is_id_open(&ctx, popup), "the button opens the colour grid");
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn");
// The first theme swatch: under the "Theme Colors" label at the popup's top left.
click(&ctx, area.min + vec2(14.0, 38.0), &mut a, &mut frame);
assert!(colour(&a).is_some(), "a swatch gives the item its own colour");
let button = ctx.read_response(color_button_id(1)).unwrap();
click(&ctx, button.rect.center(), &mut a, &mut frame);
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn again");
click(&ctx, pos2(area.min.x + 30.0, area.max.y - 14.0), &mut a, &mut frame);
assert_eq!(colour(&a), None, "Style Color clears it");
}
/// #2832: the separators between the picker's columns made the dialog as tall as the window.
#[test]
fn the_picker_row_stays_within_its_height() {
+15 -7
View File
@@ -530,6 +530,14 @@ fn shows_pixels(d: &ObjectDrag) -> bool {
(d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none())
}
/// The pixels-per-page-point for a `w` x `h` preview, lowered so neither side exceeds the GPU's
/// `max_side` (the texture is painted into the object's rect, so a smaller one is only stretched).
fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
let room = (max_side.min(wordcraft_render::MAX_SIDE as usize).max(2) - 1) as f32;
let px = if px.is_finite() { px } else { 1.0 };
px.min(room / w.max(h).max(1.0))
}
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
/// per page point).
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
@@ -539,13 +547,13 @@ fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit
opts.dark = crate::canvas::dark_page(app);
let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display);
let b = o.bounds();
let img = wordcraft_render::render_items(
&app.session.doc,
&items,
wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h),
scale * ctx.pixels_per_point(),
&opts,
);
let max_side = ctx.input(|i| i.max_texture_side);
let px = preview_scale(scale * ctx.pixels_per_point(), b.w, b.h, max_side);
let img = wordcraft_render::render_items(&app.session.doc, &items, wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h), px, &opts);
// A texture over the device limit panics wgpu outside `Session::run`; fall back to the outline.
if img.width as usize > max_side || img.height as usize > max_side {
return None;
}
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
Some(ctx.load_texture("object-drag", ci, egui::TextureOptions::LINEAR))
}
+61
View File
@@ -914,3 +914,64 @@ fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() {
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0);
assert_eq!(h.state().session.undo_depth(), depth + 1);
}
#[test]
fn drag_preview_scale_stays_within_the_gpu_texture_limit() {
// A 14,400 pt picture at 2x on a GPU limited to 8,192 px.
let px = preview_scale(2.0, 14_400.0, 100.0, 8192);
assert!((14_400.0f32 * px).ceil() <= 8192.0);
assert_eq!(preview_scale(2.0, 100.0, 50.0, 8192), 2.0);
assert_eq!(preview_scale(2.0, 0.0, 0.0, 8192), 2.0);
assert!((100.0f32 * preview_scale(2.0, 100.0, 50.0, 1)).ceil() <= 1.0);
for bad in [f32::NAN, f32::INFINITY, 0.0] {
assert!((100.0f32 * preview_scale(bad, 100.0, 100.0, 0)).ceil() <= 2.0);
}
}
/// A large picture's drag preview remains a legal device texture.
#[test]
fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
use crate::table_resize::tests::{H, button, steps};
use egui::Event;
let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([30, 120, 210, 255]));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
let picture = InlineObject::Image {
media: "large-preview-test".into(),
w: 14_400.0,
h: 200.0,
alt: String::new(),
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 100.0, y: 100.0, ..Default::default() },
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let (mut app, _) = app_with(picture);
app.session.doc.media.insert("large-preview-test".into(), Arc::new(bytes));
app.session.touch();
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
steps(&mut h, 6);
let hit = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
let max_side = h.ctx.input(|i| i.max_texture_side);
assert!(hit.bounds().w * h.ctx.pixels_per_point() > max_side as f32, "unclamped picture exceeds the texture limit");
let at = crate::canvas::page_to_screen(h.state_mut(), hit.page, hit.rect.x + 10.0, hit.rect.y + 10.0).expect("the picture center is visible");
h.event(Event::PointerMoved(at));
button(&h, at, true);
h.step();
let moved = at + egui::vec2(24.0, 0.0);
h.event(Event::PointerMoved(moved));
steps(&mut h, 2);
let drag = h.state().canvas.obj_drag.as_ref().expect("picture drag started");
let preview = drag.preview.as_ref().expect("large picture preview is generated");
let [width, height] = preview.size();
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
+9 -1
View File
@@ -59,8 +59,9 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
app,
crate::widgets::MenuButtonOptions::new("smartArtColors", Some("Change\nColors"), "Change Colors", true, ""),
|ui, app| {
let custom = g.has_custom_colors();
for colors in SmartArtColors::ALL {
let on = g.colors == colors;
let on = g.colors == colors && !custom;
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
@@ -69,6 +70,13 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
ui.close();
}
}
// Active while items carry their own colours; set in the Text Pane.
let resp = ui.add_enabled(custom, egui::Button::new(tl!("Custom")).selected(custom).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::custom_swatches(ui.painter(), sw, &g, &theme);
if !custom {
resp.on_disabled_hover_text(tl!("Set the colors in the Text Pane to use custom colors."));
}
},
);
});
+8 -2
View File
@@ -229,12 +229,18 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
});
}
/// What a Drop Cap menu choice sends: the position, and `keep` so each selected paragraph that has
/// a drop cap keeps its own lines, distance and font (None takes it all away).
pub(super) fn drop_cap_payload(position: &str) -> serde_json::Value {
if position == "none" { json!({"position": position}) } else { json!({"position": position, "keep": true}) }
}
/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked),
/// then the Drop Cap dialog.
fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) {
let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position;
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] {
mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v}));
mi_check(ui, app, label, state.position == v, "insert.dropCap", drop_cap_payload(v));
}
ui.separator();
mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"}));
+15
View File
@@ -2,6 +2,21 @@ use super::arrange::group_menu_command;
use super::*;
use wordcraft_engine::Session;
/// #2601: Dropped / In margin send just the position and `keep`, so every selected paragraph keeps
/// its own lines, distance and font; None sends only the position.
#[test]
fn drop_cap_menu_keeps_the_options() {
use super::insert::drop_cap_payload;
assert_eq!(drop_cap_payload("dropped"), json!({"position": "dropped", "keep": true}));
assert_eq!(drop_cap_payload("margin"), json!({"position": "margin", "keep": true}));
assert_eq!(drop_cap_payload("none"), json!({"position": "none"}), "None takes it all away");
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Once")), crate::Services::default());
app.run("insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})).unwrap();
app.run("insert.dropCap", drop_cap_payload("margin")).unwrap();
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
assert_eq!((state.position, state.lines, state.distance, state.font.as_deref()), ("margin", 5, 7.0, Some("Liberation Mono")));
}
/// #4349: Insert › Shapes offers the basic shapes as a grid of drawn tiles, each named for
/// screen readers and by its tooltip; a click inserts that shape.
#[test]
+6
View File
@@ -356,6 +356,12 @@ the caret's paragraph, and drops it once the caret is anywhere else or the word
(`crates/engine/src/session.rs`), so a caret move without an edit lays only that paragraph out
again.
With File › Options › Proofing › Ignore web and file addresses on (the default), a web or e-mail
address is not marked, also when the space before it is a no-break space, a narrow no-break space
(U+202F), a figure space (U+2007) or an ideographic space. With the option off, the words in the
address are checked (`address_after_a_wide_space_is_an_internet_token`, in craft-libs'
`crates/proof/src/lib.rs`).
## Editing options (File › Options › Advanced)
| Keys / setting | Result | Test |
+21 -4
View File
@@ -42,9 +42,14 @@ overrides:
- mesa-vulkan-drivers
- xdg-desktop-portal
rpm:
# Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL
# and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package).
depends:
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
- libc.so.6()(64bit)
- libm.so.6(GLIBC_2.35)(64bit)
- libgcc_s.so.1()(64bit)
- libxkbcommon.so.0()(64bit)
- (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))
recommends:
- libwayland-client
- libX11
@@ -61,8 +66,20 @@ contents:
- src: ${STAGE}/usr/bin/wordcraft-cli
dst: /usr/bin/wordcraft-cli
file_info: { mode: 0755 }
- src: ${STAGE}/usr/share/
dst: /usr/share/
# Named one by one rather than as a tree of /usr/share: a tree would also own shared directories
# such as /usr/share/mime, which openSUSE's filesystem package owns with other modes, and RPM
# refuses to install a package that conflicts with it.
- src: ${STAGE}/usr/share/applications/ai.storyteller.wordcraft.desktop
dst: /usr/share/applications/ai.storyteller.wordcraft.desktop
- src: ${STAGE}/usr/share/mime/packages/ai.storyteller.wordcraft.xml
dst: /usr/share/mime/packages/ai.storyteller.wordcraft.xml
- src: ${STAGE}/usr/share/metainfo/ai.storyteller.wordcraft.metainfo.xml
dst: /usr/share/metainfo/ai.storyteller.wordcraft.metainfo.xml
- src: ${STAGE}/usr/share/icons/hicolor/
dst: /usr/share/icons/hicolor/
type: tree
- src: ${STAGE}/usr/share/doc/wordcraft/
dst: /usr/share/doc/wordcraft/
type: tree
scripts: