mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 01:39:56 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4c45e79a | ||
|
|
6f552abf8e | ||
|
|
5f87d7461b | ||
|
|
6a50c1e374 | ||
|
|
4123bdc09f | ||
|
|
b3da7a4d36 | ||
|
|
5e4c92bf20 | ||
|
|
61a1808f0f | ||
|
|
208facfb92 | ||
|
|
811a847e86 | ||
|
|
c9ecb1a14b | ||
|
|
c38cd8c407 | ||
|
|
991a21dee6 | ||
|
|
cbe6a9d5f7 | ||
|
|
c419985c54 | ||
|
|
1c7b5013fa |
Generated
+2
-2
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"))),
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] };
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user