Character border (Home > Font > Character Border) (#60)

* Character border (format.border): w:bdr run property, toggle command, boxed rendering

* Character border: Home > Font ribbon button and icon

* Character border: address review (plain notes), translate ribbon label, fix flaky layout test and clippy nonminimal_bool

* Tests: merge the fixture imports after merging main

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

* i18n: Spanish and Brazilian Portuguese labels for Character Border

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

---------

Co-authored-by: WordCraft agent <agent@localhost>
Co-authored-by: 999sian <999sian@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
999sian
2026-10-10 07:18:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 WordCraft agent 999sian Brandon Thomas
parent 6e73fe40e3
commit da37b7987e
20 changed files with 184 additions and 20 deletions
+4 -1
View File
@@ -250,6 +250,9 @@ pub struct CharProps {
/// Character shading (background fill).
#[serde(skip_serializing_if = "Option::is_none")]
pub shading: Option<Rgb>,
/// Character border (`w:bdr`); `style: None` = explicitly no border.
#[serde(skip_serializing_if = "Option::is_none")]
pub border: Option<Border>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vert_align: Option<VertAlign>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -306,7 +309,7 @@ impl CharProps {
/// Apply every `Some` field of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
link, ins, del);
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
+6 -1
View File
@@ -3,7 +3,9 @@
use serde::{Deserialize, Serialize};
use crate::props::{Align, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabStop, TextColor, Underline, VertAlign};
use crate::props::{
Align, Border, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabStop, TextColor, Underline, VertAlign,
};
use crate::styles::{StyleKind, StyleSheet};
/// Concrete character formatting.
@@ -21,6 +23,8 @@ pub struct ResolvedChar {
pub color: TextColor,
pub highlight: Option<Rgb>,
pub shading: Option<Rgb>,
/// Visible character border only (`nil`/`none` resolve to `None`).
pub border: Option<Border>,
pub vert_align: VertAlign,
pub caps: bool,
pub small_caps: bool,
@@ -53,6 +57,7 @@ impl ResolvedChar {
color: c.color.unwrap_or_default(),
highlight: c.highlight.and_then(Highlight::rgb),
shading: c.shading,
border: c.border.filter(Border::is_visible),
vert_align: c.vert_align.unwrap_or_default(),
caps: c.caps.unwrap_or(false),
small_caps: c.small_caps.unwrap_or(false),
+1
View File
@@ -90,6 +90,7 @@ impl PropCtx {
}
c.underline_color = k.attr("w:color").and_then(Rgb::parse);
}
"w:bdr" => c.border = Some(border(k)),
"w:shd" => c.shading = shd_fill(k),
"w:vertAlign" => {
c.vert_align = k.attr("w:val").map(|v| match v {
+3
View File
@@ -72,6 +72,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
None => w.val("w:u", u.ooxml()),
}
}
if let Some(b) = &c.border {
border_el(w, "w:bdr", b);
}
if let Some(s) = c.shading {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
}
+13 -1
View File
@@ -3,7 +3,7 @@
use std::io::Write;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, TextColor, VMerge};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
@@ -445,6 +445,18 @@ fn header_self_reference_and_escaping_targets() {
wordcraft_docx::read(&out).unwrap();
}
#[test]
fn char_border_reads_and_nil_resolves_off() {
let d = read_body(
r#"<w:p><w:r><w:rPr><w:bdr w:val="single" w:sz="8" w:space="1" w:color="FF0000"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:bdr w:val="nil"/></w:rPr><w:t>b</w:t></w:r></w:p>"#,
);
let p = paras(&d)[0];
assert_eq!(p.props_of_char(0).border, Some(Border { style: BorderStyle::Single, width: 1.0, color: Some(Rgb(0xFF, 0, 0)), space: 1.0 }));
let nil = p.props_of_char(1);
assert_eq!(nil.border.map(|b| b.style), Some(BorderStyle::None));
assert_eq!(d.styles.resolve_char(None, nil).border, None);
}
#[test]
fn anchor_alignment_reference_areas_and_distances() {
let shape = |pos: &str, dist: &str| {
+15
View File
@@ -66,6 +66,7 @@ fn every_char_prop_round_trips() {
color: Some(TextColor::Rgb(Rgb(0xAA, 0x10, 0x20))),
highlight: Some(Highlight::Turquoise),
shading: Some(Rgb(0xEE, 0xEE, 0x00)),
border: Some(Border { style: BorderStyle::Double, width: 1.5, color: Some(Rgb(0xC0, 0, 0)), space: 2.0 }),
vert_align: Some(VertAlign::Superscript),
caps: Some(true),
small_caps: Some(false),
@@ -889,6 +890,20 @@ fn ensure_empty_and_table_end_document_is_valid() {
assert_eq!(r.body.len(), 1);
}
#[test]
fn char_border_written_between_u_and_shd() {
let c =
CharProps { border: Some(Border::single(0.5)), underline: Some(Underline::Single), shading: Some(Rgb(0xFF, 0xFF, 0)), ..Default::default() };
let bytes = wordcraft_docx::write(&doc_with(vec![para_runs(&[("x", c)])])).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let bdr = r#"<w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/>"#;
assert_eq!(xml.matches(bdr).count(), 1, "{xml}");
let at = xml.find(bdr).unwrap();
assert!(xml.find("<w:u ").unwrap() < at && at < xml.find("<w:shd ").unwrap(), "{xml}");
}
#[test]
fn compatibility_mode_round_trips() {
let mut d = Document::new();
+7 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::Pos;
use wordcraft_doc::props::{CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::resolve::ResolvedChar;
use super::sel_result;
@@ -32,6 +32,11 @@ pub fn specs() -> Vec<CommandSpec> {
.key("Mod+Shift+W"),
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on)))
.params(r#"{"value"?: bool}"#),
// Note: off clears the direct property only; a border inherited from a character style stays on.
CommandSpec::new("format.border", "Character Border", "Home › Font", |s, v| {
toggle(s, v, |r| r.border.is_some(), |c, on| c.border = on.then(|| Border::single(0.5)))
})
.params(r#"{"value"?: bool}"#),
CommandSpec::new("format.doubleStrikethrough", "Double Strikethrough", "Home › Font › Font", |s, v| {
toggle(s, v, |r| r.double_strike, |c, on| c.double_strike = Some(on))
}),
@@ -407,6 +412,7 @@ pub fn state(s: &Session) -> Value {
"italic": all.iter().all(|r| r.italic),
"underline": all.iter().all(|r| r.underline != Underline::None),
"strike": all.iter().all(|r| r.strike),
"border": all.iter().all(|r| r.border.is_some()),
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
"superscript": all.iter().all(|r| r.vert_align == VertAlign::Superscript),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
+27 -1
View File
@@ -198,6 +198,32 @@ fn bold_toggles_selection_and_caret_word() {
assert_eq!(p.props_of_char(9).italic, None);
}
#[test]
fn border_toggles_and_saves() {
use wordcraft_doc::props::Border;
let border = |s: &Session| s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border;
// Saved .docx carries the border iff it is on (read back: the reader maps only `w:bdr` to `border`).
let docx_has_bdr = |s: &Session| {
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
back.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border.is_some()
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "make this boxed"}));
run(&mut s, "select.text", json!({"text": "this"}));
run(&mut s, "format.border", json!({}));
assert_eq!(border(&s), Some(Border::single(0.5)));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).border, None);
assert!(docx_has_bdr(&s));
run(&mut s, "format.border", json!({}));
assert_eq!(border(&s), None);
run(&mut s, "format.border", json!({"value": true}));
run(&mut s, "format.border", json!({"value": true}));
assert_eq!(border(&s), Some(Border::single(0.5)));
run(&mut s, "format.border", json!({"value": false}));
assert_eq!(border(&s), None);
assert!(!docx_has_bdr(&s));
}
#[test]
fn pending_format_applies_to_typing() {
let mut s = s();
@@ -248,7 +274,7 @@ fn applying_a_heading_clears_list_numbering() {
);
run(&mut s, style_cmd, json!({}));
assert!(
!s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0),
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_none_or(|n| n.num == 0),
"{style_cmd}: should clear direct list numbering"
);
}
+42 -12
View File
@@ -1,7 +1,7 @@
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::props::{BorderStyle, Rgb, TextColor, Underline};
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, Underline};
use wordcraft_doc::{Document, Path, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
@@ -131,19 +131,34 @@ fn border_stroke(s: BorderStyle) -> Stroke {
}
}
/// A border line from `(x0, y0)` to `(x1, y1)` (paragraph rules and character borders).
fn rule(x0: f32, y0: f32, x1: f32, y1: f32, b: &Border, alpha: f32) -> Draw {
Draw::Line {
x0,
y0,
x1,
y1,
width: if b.style == BorderStyle::Thick { b.width.max(1.5) } else { b.width.max(0.25) },
color: b.color.unwrap_or(Rgb::BLACK),
stroke: border_stroke(b.style),
alpha,
}
}
/// Closed box around one line segment of a character-border group, widened by `space`.
// Note: border drawn outside text advance, reserve width in line breaking if overlap shows.
fn char_box(b: &Border, x0: f32, x1: f32, top: f32, bottom: f32, alpha: f32, out: &mut Vec<Draw>) {
let (l, r) = (x0 - b.space, x1 + b.space);
out.push(rule(l, top, r, top, b, alpha));
out.push(rule(l, bottom, r, bottom, b, alpha));
out.push(rule(l, top, l, bottom, b, alpha));
out.push(rule(r, top, r, bottom, b, alpha));
}
fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
match it {
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
Placed::Rule { x0, y0, x1, y1, border } => out.push(Draw::Line {
x0: *x0,
y0: *y0,
x1: *x1,
y1: *y1,
width: if border.style == BorderStyle::Thick { border.width.max(1.5) } else { border.width.max(0.25) },
color: border.color.unwrap_or(Rgb::BLACK),
stroke: border_stroke(border.style),
alpha,
}),
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
@@ -202,7 +217,8 @@ fn lines(
}
}
}
// Backgrounds first: highlight and character shading.
// Backgrounds first: highlight and character shading; character borders group equal adjacent borders.
let mut open: Option<(Border, f32, f32)> = None;
for k in line.c0..line.c1 {
let (Some(c), Some(cx), Some(nx)) = (pl.clusters.get(k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue };
let Some(st) = pl.styles.get(c.style as usize) else { continue };
@@ -212,6 +228,20 @@ fn lines(
if let Some(h) = st.rc.highlight.or(st.rc.shading) {
out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha });
}
let b = st.rc.border;
if let Some((ob, _, x1)) = open.as_mut()
&& Some(*ob) == b
{
*x1 = x + nx;
continue;
}
if let Some((ob, x0, x1)) = open.take() {
char_box(&ob, x0, x1, top, bottom, alpha, out);
}
open = b.map(|b| (b, x + cx, x + nx));
}
if let Some((ob, x0, x1)) = open {
char_box(&ob, x0, x1, top, bottom, alpha, out);
}
// Label.
if li == 0
+1 -1
View File
@@ -228,7 +228,7 @@ impl DocLayout {
let Some(l) = para.lines.get(li) else { continue };
let from = if path == a.path { a.off.max(l.start) } else { l.start };
let to = if path == b.path { b.off.min(l.stop) } else { l.stop };
if path == a.path && a.off > l.stop && !(l.stop == a.off) {
if path == a.path && a.off > l.stop {
continue;
}
if path == b.path && b.off < l.start {
+1 -1
View File
@@ -194,7 +194,7 @@ impl<'a> Builder<'a> {
face.id(),
rc.strike || rc.double_strike || rc.link.is_some(),
);
let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden), key.1, key.2);
let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}|{:?}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden, rc.border), key.1, key.2);
if let Some(i) = self.style_index.get(&key) {
return *i;
}
+54
View File
@@ -275,6 +275,60 @@ fn display_has_glyphs_and_marks() {
assert!(items.iter().any(|i| matches!(i, display::Draw::Mark { ch: '→', .. })));
}
fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> {
border_lines_in(d, &lay(d))
}
fn border_lines_in(d: &Document, l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
display::page_display(d, &l.pages[0], &display::DisplayOptions::default())
.into_iter()
.filter_map(|i| if let display::Draw::Line { x0, y0, x1, y1, .. } = i { Some((x0, y0, x1, y1)) } else { None })
.collect()
}
#[test]
fn character_border_draws_one_box_around_run() {
use wordcraft_doc::props::Border;
let mut d = Document::from_text("Hello world");
assert!(border_lines(&d).is_empty());
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.border = Some(Border::single(0.5))).unwrap();
// One layout for both the draw list and the expected geometry: the background font scan can change metrics between layouts.
let l = lay(&d);
let lines = border_lines_in(&d, &l);
assert_eq!(lines.len(), 4, "{lines:?}");
let Some(Placed::Lines { para, x, y, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let (gx0, gx1) = (x + para.x_of(0, 0).unwrap(), x + para.x_of(0, 5).unwrap());
let (top, bottom) = (*y, y + para.lines[0].height);
let horiz: Vec<_> = lines.iter().filter(|l| l.1 == l.3).collect();
let vert: Vec<_> = lines.iter().filter(|l| l.0 == l.2).collect();
assert_eq!(horiz.len(), 2);
assert_eq!(vert.len(), 2);
assert!(horiz.iter().any(|h| (h.1 - top).abs() < 0.01) && horiz.iter().any(|h| (h.1 - bottom).abs() < 0.01), "{lines:?}");
assert!(horiz.iter().all(|h| (h.0 - gx0).abs() < 0.01 && (h.2 - gx1).abs() < 0.01), "{lines:?} {gx0} {gx1}");
}
#[test]
fn equal_adjacent_borders_share_a_box() {
use wordcraft_doc::props::Border;
let mut d = Document::from_text("ab");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.border = Some(Border::single(0.5))).unwrap();
d.format_range(&Pos::body(0, 1), &Pos::body(0, 2), &|c| c.bold = Some(true)).unwrap();
assert_eq!(border_lines(&d).len(), 4);
d.format_range(&Pos::body(0, 1), &Pos::body(0, 2), &|c| c.border = Some(Border::single(1.0))).unwrap();
assert_eq!(border_lines(&d).len(), 8);
}
#[test]
fn wrapped_border_boxes_each_line() {
use wordcraft_doc::props::Border;
let text = "boxed ".repeat(20);
let mut d = Document::from_text(text.trim_end());
let n = d.para_at(&Pos::body(0, 0)).unwrap().len();
d.format_range(&Pos::body(0, 0), &Pos::body(0, n), &|c| c.border = Some(Border::single(0.5))).unwrap();
assert_eq!(lines_of(&lay(&d)), 2);
assert_eq!(border_lines(&d).len(), 8);
}
#[test]
fn web_view_is_one_page() {
let d = Document::from_text(&"text ".repeat(3000));
+1
View File
@@ -793,3 +793,4 @@
Dark page (white text on black) Página oscura (texto blanco sobre negro)
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Muestra los documentos con los colores invertidos, como en Vista › Cambiar modos. Guardar, imprimir y los PDF no cambian.
Only web (http, https) and email (mailto) links can be followed Solo se pueden seguir vínculos web (http, https) y de correo electrónico (mailto)
Character Border Borde de carácter
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -99,6 +99,7 @@
Character Scale 文字の拡大/縮小
Character Shading 文字の網かけ
Character Spacing 文字間隔
Character Border 文字の罫線
Characters (no spaces) 文字数(スペースを含めない)
Characters (with spaces) 文字数(スペースを含める)
Chart グラフ
1 # Japanese (ja) interface catalog. Same format and clean-room rules as zh-hans.tsv.
99
100
101
102
103
104
105
+1
View File
@@ -788,3 +788,4 @@
Apply Styles Aplicar estilos
Scroll ribbon left Rolar a faixa de opções para a esquerda
Scroll ribbon right Rolar a faixa de opções para a direita
Character Border Borda de caractere
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -110,6 +110,7 @@
Character Scale 字符缩放
Character Shading 字符底纹
Character Spacing 字符间距
Character Border 字符边框
Characters (no spaces) 字符数(不计空格)
Characters (with spaces) 字符数(计空格)
Chart 图表
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -100,6 +100,7 @@
Character Scale 字元縮放
Character Shading 字元網底
Character Spacing 字元間距
Character Border 字元邊框
Characters (no spaces) 字元數(不含空格)
Characters (with spaces) 字元數(含空格)
Chart 圖表
1 # Traditional Chinese (zh-hant, Taiwan vocabulary) interface catalog. Same format and clean-room
100
101
102
103
104
105
106
+4
View File
@@ -116,6 +116,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.text(10.0, 10.0, 12.0, "ab", c, false);
pen.line_c(&[(3.0, 10.5), (17.0, 10.5)], c);
}
"charborder" => {
pen.rect(3.0, 3.0, 17.0, 17.0, a);
pen.text(10.0, 6.0, 9.0, "A", c, false);
}
"subscript" => {
pen.text(8.0, 9.0, 13.0, "x", c, false);
pen.text(15.0, 14.5, 8.0, "2", a, true);
+1
View File
@@ -273,6 +273,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike"));
small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript"));
small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript"));
small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border"));
ui.add_space(4.0);
menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| {
mi(ui, app, "Outline", "format.outline", json!({}));
-1
View File
@@ -31,7 +31,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Clipboard › Paste Special (`edit.pasteSpecial`)
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
- Font › Character Border (`format.border`)
- Font › Phonetic Guide (`format.phonetic`)
- Font › Enclose Characters (`format.enclose`)
- Paragraph › Asian Typography (`para.asianTypography`)