| 1 | pub mod page; |
| 2 | #[cfg(test)] |
| 3 | mod profile; |
| 4 | mod tag_art; |
| 5 | pub use tag_art::{TagArt, art_sources, import as import_tag_art}; |
| 6 | |
| 7 | use crate::{ |
| 8 | layout::{TextBrush, TextLayout}, |
| 9 | outline::TagIcon, |
| 10 | }; |
| 11 | use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; |
| 12 | use parley::PositionedLayoutItem; |
| 13 | |
| 14 | #[derive(Clone, Copy)] |
| 15 | pub struct Viewport { |
| 16 | pub size: [u32; 2], |
| 17 | /// Physical pixels per document point. |
| 18 | pub scale: f32, |
| 19 | /// Physical pixel position of the document origin. |
| 20 | pub origin: [f32; 2], |
| 21 | } |
| 22 | |
| 23 | impl Viewport { |
| 24 | pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] { |
| 25 | [ |
| 26 | (point[0] - self.origin[0]) / self.scale, |
| 27 | (point[1] - self.origin[1]) / self.scale, |
| 28 | ] |
| 29 | } |
| 30 | |
| 31 | /// Document-point primitives drawn through this viewport. |
| 32 | pub fn layer<'a>(&self, primitives: &'a [Primitive<'a>]) -> Layer<'a> { |
| 33 | Layer { |
| 34 | scale: self.scale, |
| 35 | origin: self.origin, |
| 36 | clip: None, |
| 37 | backdrop: None, |
| 38 | round: None, |
| 39 | motion: None, |
| 40 | primitives, |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// The page's paper and the ink that content in OneNote's automatic colour draws in, |
| 46 | /// linear RGBA. |
| 47 | #[derive(Clone, Copy, Debug)] |
| 48 | pub struct Paper { |
| 49 | pub color: [f32; 4], |
| 50 | pub ink: [f32; 4], |
| 51 | } |
| 52 | |
| 53 | impl Paper { |
| 54 | pub const WHITE: Self = Self { |
| 55 | color: [1.0; 4], |
| 56 | ink: [0.0, 0.0, 0.0, 1.0], |
| 57 | }; |
| 58 | |
| 59 | /// This paper under a page coloured `color` (COLORREF, OneNote's View, Page Color): the |
| 60 | /// colour as OneNote paints it on white paper, moved onto this paper as a fill is, so |
| 61 | /// dark paper takes the colour's hue at its own depth. Text keeps its ink; template art |
| 62 | /// lies over the colour. |
| 63 | pub fn colored(self, color: Option<u32>) -> Self { |
| 64 | match color { |
| 65 | Some(color) => Self { |
| 66 | color: self.tint(colorref(color)), |
| 67 | ..self |
| 68 | }, |
| 69 | None => self, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// A fill OneNote draws on white paper, moved onto this paper: as far from it in |
| 74 | /// OKLab lightness as from white, in the same hue and chroma, so text keeps its |
| 75 | /// contrast on it. |
| 76 | pub fn tint(&self, light: [f32; 4]) -> [f32; 4] { |
| 77 | let [lightness, a, b] = draw::oklab(light); |
| 78 | let [paper, ..] = draw::oklab(self.color); |
| 79 | let distance = 1.0 - lightness; |
| 80 | let lightness = if paper < 0.5 { |
| 81 | paper + distance |
| 82 | } else { |
| 83 | paper - distance |
| 84 | }; |
| 85 | let [red, green, blue] = draw::from_oklab([lightness, a, b]); |
| 86 | [red, green, blue, light[3]] |
| 87 | } |
| 88 | |
| 89 | /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its |
| 90 | /// darkness becomes ink and its tint tints the paper. |
| 91 | pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] { |
| 92 | let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0; |
| 93 | let mut shade = light; |
| 94 | for channel in 0..3 { |
| 95 | shade[channel] = self.color[channel] * light[channel] + self.ink[channel] * ink; |
| 96 | } |
| 97 | shade |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | impl Glyphs for TextLayout { |
| 102 | fn runs( |
| 103 | &self, |
| 104 | paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, |
| 105 | ) -> Result<(), RenderError> { |
| 106 | for (line, bounds) in self.lines() { |
| 107 | for item in line.items() { |
| 108 | let PositionedLayoutItem::GlyphRun(run) = item else { |
| 109 | continue; |
| 110 | }; |
| 111 | draw::paint_parley_run( |
| 112 | &run, |
| 113 | &self.text, |
| 114 | bounds.baseline, |
| 115 | run.style().brush.rise, |
| 116 | [bounds.top, bounds.height], |
| 117 | // Automatic text on a highlight stays black, but on a black highlight, which |
| 118 | // paints in the ink, it stays ink. |
| 119 | |brush: &TextBrush| { |
| 120 | brush |
| 121 | .color |
| 122 | .or(brush.highlight.filter(|&color| color != 0).map(|_| 0)) |
| 123 | .map(colorref) |
| 124 | }, |
| 125 | run.style() |
| 126 | .brush |
| 127 | .highlight |
| 128 | .filter(|&color| color != 0) |
| 129 | .map(colorref), |
| 130 | paint, |
| 131 | )?; |
| 132 | } |
| 133 | } |
| 134 | Ok(()) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /// The layout a text primitive paints. |
| 139 | #[cfg(test)] |
| 140 | pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { |
| 141 | (text as &dyn std::any::Any) |
| 142 | .downcast_ref() |
| 143 | .expect("Page text is a TextLayout") |
| 144 | } |
| 145 | |
| 146 | const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); |
| 147 | const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); |
| 148 | const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); |
| 149 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); |
| 150 | |
| 151 | /// How a tag symbol's artwork takes a check: a large box under it, a small box with the |
| 152 | /// check between the box and its badge, or art that shows none. |
| 153 | enum Art { |
| 154 | Box(&'static str), |
| 155 | Badged(&'static str), |
| 156 | Plain(&'static str), |
| 157 | } |
| 158 | |
| 159 | /// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the |
| 160 | /// page. A shape MS-ONE does not list shows a plain tag. |
| 161 | fn art(shape: u16) -> Art { |
| 162 | use Art::{Badged, Box, Plain}; |
| 163 | macro_rules! tag { |
| 164 | ($name:literal) => { |
| 165 | include_str!(concat!("../../assets/tags/", $name, ".svg")) |
| 166 | }; |
| 167 | } |
| 168 | match shape { |
| 169 | 1 => Box(tag!("checkbox-green")), |
| 170 | 2 => Box(tag!("checkbox-yellow")), |
| 171 | 3 => Box(tag!("checkbox")), |
| 172 | 4 => Badged(tag!("mark-star-green")), |
| 173 | 5 => Badged(tag!("mark-star-yellow")), |
| 174 | 6 => Badged(tag!("mark-star-blue")), |
| 175 | 7 => Badged(tag!("mark-exclamation-green")), |
| 176 | 8 => Badged(tag!("mark-exclamation-yellow")), |
| 177 | 9 => Badged(tag!("mark-exclamation-blue")), |
| 178 | 10 => Badged(tag!("mark-arrow-green")), |
| 179 | 11 => Badged(tag!("mark-arrow-yellow")), |
| 180 | 12 => Badged(tag!("mark-arrow-blue")), |
| 181 | 13 => Plain(tag!("star")), |
| 182 | 14 => Plain(tag!("follow-up")), |
| 183 | 15 => Plain(tag!("question")), |
| 184 | 16 => Plain(tag!("arrow-right-blue")), |
| 185 | 17 => Plain(tag!("exclamation")), |
| 186 | 18 => Plain(tag!("phone")), |
| 187 | 19 => Plain(tag!("calendar")), |
| 188 | 20 => Plain(tag!("clock")), |
| 189 | 21 => Plain(tag!("idea")), |
| 190 | 22 => Plain(tag!("pushpin")), |
| 191 | 23 => Plain(tag!("address")), |
| 192 | 24 => Plain(tag!("blog")), |
| 193 | 25 => Plain(tag!("smiley")), |
| 194 | 26 => Plain(tag!("ribbon")), |
| 195 | 27 => Plain(tag!("key")), |
| 196 | 28 => Badged(tag!("mark-one-blue")), |
| 197 | 29 => Plain(tag!("circle-1-blue")), |
| 198 | 30 => Badged(tag!("mark-two-blue")), |
| 199 | 31 => Plain(tag!("circle-2-blue")), |
| 200 | 32 => Badged(tag!("mark-three-blue")), |
| 201 | 33 => Plain(tag!("circle-3-blue")), |
| 202 | 34 => Plain(tag!("star8-blue")), |
| 203 | 35 => Plain(tag!("tick-blue")), |
| 204 | 36 => Plain(tag!("circle-blue")), |
| 205 | 37 => Plain(tag!("arrow-down-blue")), |
| 206 | 38 => Plain(tag!("arrow-left-blue")), |
| 207 | 39 => Plain(tag!("solid-target-blue")), |
| 208 | 40 => Plain(tag!("star-blue")), |
| 209 | 41 => Plain(tag!("sun-blue")), |
| 210 | 42 => Plain(tag!("target-blue")), |
| 211 | 43 => Plain(tag!("triangle-blue")), |
| 212 | 44 => Plain(tag!("umbrella-blue")), |
| 213 | 45 => Plain(tag!("arrow-up-blue")), |
| 214 | 46 => Plain(tag!("x-dots-blue")), |
| 215 | 47 => Plain(tag!("x-blue")), |
| 216 | 48 => Badged(tag!("mark-one-green")), |
| 217 | 49 => Plain(tag!("circle-1-green")), |
| 218 | 50 => Badged(tag!("mark-two-green")), |
| 219 | 51 => Plain(tag!("circle-2-green")), |
| 220 | 52 => Badged(tag!("mark-three-green")), |
| 221 | 53 => Plain(tag!("circle-3-green")), |
| 222 | 54 => Plain(tag!("star8-green")), |
| 223 | 55 => Plain(tag!("tick-green")), |
| 224 | 56 => Plain(tag!("circle-green")), |
| 225 | 57 => Plain(tag!("arrow-down-green")), |
| 226 | 58 => Plain(tag!("arrow-left-green")), |
| 227 | 59 => Plain(tag!("arrow-right-green")), |
| 228 | 60 => Plain(tag!("solid-target-green")), |
| 229 | 61 => Plain(tag!("star-green")), |
| 230 | 62 => Plain(tag!("sun-green")), |
| 231 | 63 => Plain(tag!("target-green")), |
| 232 | 64 => Plain(tag!("triangle-green")), |
| 233 | 65 => Plain(tag!("umbrella-green")), |
| 234 | 66 => Plain(tag!("arrow-up-green")), |
| 235 | 67 => Plain(tag!("x-dots-green")), |
| 236 | 68 => Plain(tag!("x-green")), |
| 237 | 69 => Badged(tag!("mark-one-yellow")), |
| 238 | 70 => Plain(tag!("circle-1-yellow")), |
| 239 | 71 => Badged(tag!("mark-two-yellow")), |
| 240 | 72 => Plain(tag!("circle-2-yellow")), |
| 241 | 73 => Badged(tag!("mark-three-yellow")), |
| 242 | 74 => Plain(tag!("circle-3-yellow")), |
| 243 | 75 => Plain(tag!("star8-yellow")), |
| 244 | 76 => Plain(tag!("tick-yellow")), |
| 245 | 77 => Plain(tag!("circle-yellow")), |
| 246 | 78 => Plain(tag!("arrow-down-yellow")), |
| 247 | 79 => Plain(tag!("arrow-left-yellow")), |
| 248 | 80 => Plain(tag!("arrow-right-yellow")), |
| 249 | 81 => Plain(tag!("solid-target-yellow")), |
| 250 | 82 => Plain(tag!("sun-yellow")), |
| 251 | 83 => Plain(tag!("target-yellow")), |
| 252 | 84 => Plain(tag!("triangle-yellow")), |
| 253 | 85 => Plain(tag!("umbrella-yellow")), |
| 254 | 86 => Plain(tag!("arrow-up-yellow")), |
| 255 | 87 => Plain(tag!("x-dots-yellow")), |
| 256 | 88 => Plain(tag!("x-yellow")), |
| 257 | 89 => Plain(tag!("flag-today")), |
| 258 | 90 => Plain(tag!("flag-tomorrow")), |
| 259 | 91 => Plain(tag!("flag-this-week")), |
| 260 | 92 => Plain(tag!("flag-next-week")), |
| 261 | 93 => Plain(tag!("flag-no-date")), |
| 262 | 94 => Badged(tag!("mark-person-blue")), |
| 263 | 95 => Badged(tag!("mark-person-yellow")), |
| 264 | 96 => Badged(tag!("mark-person-green")), |
| 265 | 97 => Badged(tag!("mark-flag-blue")), |
| 266 | 98 => Badged(tag!("mark-flag-yellow")), |
| 267 | 99 => Badged(tag!("mark-flag-green")), |
| 268 | 100 => Plain(tag!("red-square")), |
| 269 | 101 => Plain(tag!("yellow-square")), |
| 270 | 102 => Plain(tag!("blue-square")), |
| 271 | 103 => Plain(tag!("green-square")), |
| 272 | 104 => Plain(tag!("orange-square")), |
| 273 | 105 => Plain(tag!("pink-square")), |
| 274 | 106 => Plain(tag!("email")), |
| 275 | 107 => Plain(tag!("envelope")), |
| 276 | 108 => Plain(tag!("envelope-open")), |
| 277 | 109 => Plain(tag!("mobile")), |
| 278 | 110 => Plain(tag!("phone-clock")), |
| 279 | 111 => Plain(tag!("question-balloon")), |
| 280 | 112 => Plain(tag!("paperclip")), |
| 281 | 113 => Plain(tag!("frown")), |
| 282 | 114 => Plain(tag!("im-contact")), |
| 283 | 115 => Plain(tag!("person")), |
| 284 | 116 => Plain(tag!("people")), |
| 285 | 117 => Plain(tag!("bell")), |
| 286 | 118 => Plain(tag!("contact")), |
| 287 | 119 => Plain(tag!("rose")), |
| 288 | 120 => Plain(tag!("date")), |
| 289 | 121 => Plain(tag!("music")), |
| 290 | 122 => Plain(tag!("movie")), |
| 291 | 123 => Plain(tag!("quote")), |
| 292 | 124 => Plain(tag!("globe")), |
| 293 | 125 => Plain(tag!("web")), |
| 294 | 126 => Plain(tag!("laptop")), |
| 295 | 127 => Plain(tag!("plane")), |
| 296 | 128 => Plain(tag!("car")), |
| 297 | 129 => Plain(tag!("binoculars")), |
| 298 | 130 => Plain(tag!("presentation")), |
| 299 | 131 => Plain(tag!("password")), |
| 300 | 132 => Plain(tag!("book")), |
| 301 | 133 => Plain(tag!("notebook")), |
| 302 | 134 => Plain(tag!("paper")), |
| 303 | 135 => Plain(tag!("research")), |
| 304 | 136 => Plain(tag!("highlight")), |
| 305 | 137 => Plain(tag!("dollar")), |
| 306 | 138 => Plain(tag!("coins")), |
| 307 | 139 => Plain(tag!("schedule")), |
| 308 | 140 => Plain(tag!("lightning")), |
| 309 | 141 => Plain(tag!("cloud")), |
| 310 | 142 => Plain(tag!("heart")), |
| 311 | 143 => Plain(tag!("sunflower")), |
| 312 | _ => Plain(TAG), |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /// Symbols MS-ONE lists. |
| 317 | const SYMBOLS: u16 = 143; |
| 318 | |
| 319 | /// The tag's artwork, drawn in order. |
| 320 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 321 | static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new(); |
| 322 | let (shape, checked) = match icon { |
| 323 | TagIcon::Symbol { shape, checked } => (shape, checked), |
| 324 | TagIcon::Task { shape } => (shape, false), |
| 325 | }; |
| 326 | let sources = SOURCES.get_or_init(|| { |
| 327 | (0..=SYMBOLS) |
| 328 | .map(|shape| match art(shape) { |
| 329 | Art::Box(art) => [vec![art], vec![art, CHECKMARK]], |
| 330 | Art::Badged(badge) => [vec![BOX_SMALL, badge], vec![BOX_SMALL, CHECK_SMALL, badge]], |
| 331 | Art::Plain(art) => [vec![art], vec![art]], |
| 332 | }) |
| 333 | .collect() |
| 334 | }); |
| 335 | match sources.get(usize::from(shape)) { |
| 336 | Some(sources) => &sources[usize::from(checked)], |
| 337 | None => &[TAG], |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | /// A COLORREF (0x00BBGGRR) as linear RGBA. |
| 342 | pub fn colorref(color: u32) -> [f32; 4] { |
| 343 | let [red, green, blue, _] = color.to_le_bytes(); |
| 344 | draw::srgb(red, green, blue) |
| 345 | } |
| 346 | |
| 347 | #[cfg(test)] |
| 348 | mod tests { |
| 349 | use super::*; |
| 350 | use crate::layout::TextEngine; |
| 351 | use onestore::document::Format; |
| 352 | use onestore::page::text::Paragraph; |
| 353 | use std::time::Duration; |
| 354 | |
| 355 | /// OneNote 2010 draws paragraph `shape N` of `corpus/custom-tags/native/shapes.one` with |
| 356 | /// the art `DRAWN`'s Nth word names (its render: `shapes-1-72.png`, `shapes-73-143.png`); |
| 357 | /// each paragraph's tag definition stores the NoteTagShape. |
| 358 | #[test] |
| 359 | fn symbols_draw_the_art_onenote_draws_for_their_number() { |
| 360 | use onestore::page::{PageObject, ParagraphContent}; |
| 361 | use onestore::{RevisionIndex, Store, document::Document, document::Kind, page::Page}; |
| 362 | const DRAWN: &str = "\ |
| 363 | checkbox-green checkbox-yellow checkbox mark-star-green mark-star-yellow \ |
| 364 | mark-star-blue mark-exclamation-green mark-exclamation-yellow mark-exclamation-blue \ |
| 365 | mark-arrow-green mark-arrow-yellow mark-arrow-blue star follow-up question \ |
| 366 | arrow-right-blue exclamation phone calendar clock idea pushpin address blog smiley \ |
| 367 | ribbon key mark-one-blue circle-1-blue mark-two-blue circle-2-blue mark-three-blue \ |
| 368 | circle-3-blue star8-blue tick-blue circle-blue arrow-down-blue arrow-left-blue \ |
| 369 | solid-target-blue star-blue sun-blue target-blue triangle-blue umbrella-blue \ |
| 370 | arrow-up-blue x-dots-blue x-blue mark-one-green circle-1-green mark-two-green \ |
| 371 | circle-2-green mark-three-green circle-3-green star8-green tick-green circle-green \ |
| 372 | arrow-down-green arrow-left-green arrow-right-green solid-target-green star-green \ |
| 373 | sun-green target-green triangle-green umbrella-green arrow-up-green x-dots-green \ |
| 374 | x-green mark-one-yellow circle-1-yellow mark-two-yellow circle-2-yellow \ |
| 375 | mark-three-yellow circle-3-yellow star8-yellow tick-yellow circle-yellow \ |
| 376 | arrow-down-yellow arrow-left-yellow arrow-right-yellow solid-target-yellow \ |
| 377 | sun-yellow target-yellow triangle-yellow umbrella-yellow arrow-up-yellow \ |
| 378 | x-dots-yellow x-yellow flag-today flag-tomorrow flag-this-week flag-next-week \ |
| 379 | flag-no-date mark-person-blue mark-person-yellow mark-person-green mark-flag-blue \ |
| 380 | mark-flag-yellow mark-flag-green red-square yellow-square blue-square green-square \ |
| 381 | orange-square pink-square email envelope envelope-open mobile phone-clock \ |
| 382 | question-balloon paperclip frown im-contact person people bell contact rose date \ |
| 383 | music movie quote globe web laptop plane car binoculars presentation password book \ |
| 384 | notebook paper research highlight dollar coins schedule lightning cloud heart \ |
| 385 | sunflower"; |
| 386 | let bytes = include_bytes!("../../../../corpus/custom-tags/native/shapes.one"); |
| 387 | let store = Store::parse(bytes).unwrap(); |
| 388 | let index = RevisionIndex::parse(&store).unwrap(); |
| 389 | let document = Document::parse(&index).unwrap(); |
| 390 | let files: Vec<&str> = DRAWN.split_whitespace().collect(); |
| 391 | let mut seen = Vec::new(); |
| 392 | for (space, _) in document.pages().unwrap() { |
| 393 | let page = Page::from_space(&document, space).unwrap(); |
| 394 | if !page.title.starts_with("Shapes") { |
| 395 | continue; |
| 396 | } |
| 397 | for object in &page.objects { |
| 398 | let PageObject::Outline(outline) = object else { |
| 399 | continue; |
| 400 | }; |
| 401 | for paragraph in &outline.paragraphs { |
| 402 | let ParagraphContent::Text(text) = &paragraph.content else { |
| 403 | continue; |
| 404 | }; |
| 405 | let label: usize = text.text.text()["shape ".len()..].parse().unwrap(); |
| 406 | let definition = text.tags[0].definition.unwrap(); |
| 407 | let Kind::TagDefinition { |
| 408 | shape: Some(shape), .. |
| 409 | } = page.definitions[&definition].kind |
| 410 | else { |
| 411 | panic!("shape {label} stores no symbol"); |
| 412 | }; |
| 413 | let (Art::Box(drawn) | Art::Badged(drawn) | Art::Plain(drawn)) = art(shape); |
| 414 | let file = files[label - 1]; |
| 415 | let expected = std::fs::read_to_string(format!( |
| 416 | "{}/assets/tags/{file}.svg", |
| 417 | env!("CARGO_MANIFEST_DIR") |
| 418 | )) |
| 419 | .unwrap(); |
| 420 | assert_eq!( |
| 421 | drawn, expected, |
| 422 | "shape {label}, stored {shape}, draws {file}" |
| 423 | ); |
| 424 | seen.push(label); |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | seen.sort_unstable(); |
| 429 | assert_eq!(seen, (1..=143).collect::<Vec<_>>()); |
| 430 | } |
| 431 | |
| 432 | /// A page colour paints as OneNote paints it on white paper, and on dark paper as a |
| 433 | /// dark paper of its hue; no colour leaves the paper as it was. |
| 434 | #[test] |
| 435 | fn page_colours_colour_the_paper() { |
| 436 | let teal = 0x00f2f9d4; |
| 437 | assert_eq!( |
| 438 | Paper::WHITE |
| 439 | .colored(Some(teal)) |
| 440 | .color |
| 441 | .map(|c| (c * 1000.0).round()), |
| 442 | colorref(teal).map(|c| (c * 1000.0).round()) |
| 443 | ); |
| 444 | let dark = Paper { |
| 445 | color: draw::srgb(0x1f, 0x20, 0x22), |
| 446 | ink: draw::srgb(0xe6, 0xe6, 0xe6), |
| 447 | }; |
| 448 | let colored = dark.colored(Some(teal)); |
| 449 | let [lightness, a, b] = draw::oklab(colored.color); |
| 450 | let [paper, ..] = draw::oklab(dark.color); |
| 451 | let [_, ta, tb] = draw::oklab(colorref(teal)); |
| 452 | assert!(lightness > paper && lightness < 0.5); |
| 453 | assert!((a - ta).abs() < 1e-3 && (b - tb).abs() < 1e-3); |
| 454 | assert_eq!(colored.ink, dark.ink); |
| 455 | assert_eq!(dark.colored(None).color, dark.color); |
| 456 | } |
| 457 | |
| 458 | #[test] |
| 459 | fn runs_carry_their_highlight_as_what_lies_behind_them() { |
| 460 | let run = |highlight, color| { |
| 461 | ( |
| 462 | "text".into(), |
| 463 | Format { |
| 464 | highlight, |
| 465 | color, |
| 466 | ..Format::default() |
| 467 | }, |
| 468 | ) |
| 469 | }; |
| 470 | let text = TextEngine::default() |
| 471 | .layout( |
| 472 | &Paragraph::from_runs([ |
| 473 | run(None, None), |
| 474 | run(Some(0x0000ffff), None), |
| 475 | run(Some(0x0000ffff), Some(0x000000ff)), |
| 476 | run(Some(0), None), |
| 477 | ]), |
| 478 | 400.0, |
| 479 | ) |
| 480 | .unwrap(); |
| 481 | let mut painted = Vec::new(); |
| 482 | text.runs(&mut |run| { |
| 483 | painted.push((run.color, run.backdrop)); |
| 484 | Ok(()) |
| 485 | }) |
| 486 | .unwrap(); |
| 487 | let [black, red, yellow] = [0, 0x000000ff, 0x0000ffff].map(colorref); |
| 488 | assert_eq!( |
| 489 | painted, |
| 490 | [ |
| 491 | (None, None), |
| 492 | (Some(black), Some(yellow)), |
| 493 | (Some(red), Some(yellow)), |
| 494 | // A black highlight paints in the ink, as its automatic text does. |
| 495 | (None, None), |
| 496 | ] |
| 497 | ); |
| 498 | } |
| 499 | |
| 500 | #[test] |
| 501 | #[ignore = "requires a native GPU adapter"] |
| 502 | fn tags_and_page_text_paint_through_the_renderer() { |
| 503 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 504 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); |
| 505 | let (device, queue) = |
| 506 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); |
| 507 | let size = [448, 64]; |
| 508 | let target = device.create_texture(&wgpu::TextureDescriptor { |
| 509 | label: Some("Tag readback test"), |
| 510 | size: wgpu::Extent3d { |
| 511 | width: size[0], |
| 512 | height: size[1], |
| 513 | depth_or_array_layers: 1, |
| 514 | }, |
| 515 | mip_level_count: 1, |
| 516 | sample_count: 1, |
| 517 | dimension: wgpu::TextureDimension::D2, |
| 518 | format: wgpu::TextureFormat::Rgba8UnormSrgb, |
| 519 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, |
| 520 | view_formats: &[], |
| 521 | }); |
| 522 | let readback = device.create_buffer(&wgpu::BufferDescriptor { |
| 523 | label: Some("Tag readback"), |
| 524 | size: u64::from(size[0] * size[1] * 4), |
| 525 | usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, |
| 526 | mapped_at_creation: false, |
| 527 | }); |
| 528 | let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); |
| 529 | let icons: Vec<_> = [ |
| 530 | (3, false), |
| 531 | (3, true), |
| 532 | (13, false), |
| 533 | (15, false), |
| 534 | (136, false), |
| 535 | (118, false), |
| 536 | (23, false), |
| 537 | (18, false), |
| 538 | (121, false), |
| 539 | (17, false), |
| 540 | (100, false), |
| 541 | (101, false), |
| 542 | (102, false), |
| 543 | (999, false), |
| 544 | ] |
| 545 | .into_iter() |
| 546 | .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap()) |
| 547 | .chain([TagIcon::Task { shape: 91 }]) |
| 548 | .collect(); |
| 549 | let text = TextEngine::default() |
| 550 | .layout( |
| 551 | &Paragraph::from_runs([ |
| 552 | ( |
| 553 | "Tagged".into(), |
| 554 | Format { |
| 555 | color: Some(0x000000ff), |
| 556 | underline: Some(true), |
| 557 | ..Format::default() |
| 558 | }, |
| 559 | ), |
| 560 | (" automatic".into(), Format::default()), |
| 561 | ( |
| 562 | " lit".into(), |
| 563 | Format { |
| 564 | highlight: Some(0x0000ffff), |
| 565 | ..Format::default() |
| 566 | }, |
| 567 | ), |
| 568 | ]), |
| 569 | 100.0, |
| 570 | ) |
| 571 | .unwrap(); |
| 572 | let mut primitives: Vec<_> = icons |
| 573 | .iter() |
| 574 | .enumerate() |
| 575 | .map(|(index, icon)| Primitive::Icon { |
| 576 | sources: tag_sources(*icon), |
| 577 | origin: [2.0 + 16.0 * index as f32, 2.0], |
| 578 | size: crate::outline::ParagraphTag::SIZE, |
| 579 | tint: [1.0; 4], |
| 580 | palette: draw::Palette::default(), |
| 581 | }) |
| 582 | .collect(); |
| 583 | primitives.push(Primitive::Text { |
| 584 | text: &text, |
| 585 | origin: [2.0, 20.0], |
| 586 | clip: None, |
| 587 | ink: [0.0, 1.0, 0.0, 1.0], |
| 588 | }); |
| 589 | let viewport = Viewport { |
| 590 | size, |
| 591 | scale: 2.0, |
| 592 | origin: [0.0; 2], |
| 593 | }; |
| 594 | renderer |
| 595 | .draw( |
| 596 | &target.create_view(&Default::default()).into(), |
| 597 | size, |
| 598 | [1.0; 4], |
| 599 | &[viewport.layer(&primitives)], |
| 600 | ) |
| 601 | .unwrap(); |
| 602 | let mut encoder = renderer |
| 603 | .device() |
| 604 | .create_command_encoder(&Default::default()); |
| 605 | encoder.copy_texture_to_buffer( |
| 606 | target.as_image_copy(), |
| 607 | wgpu::TexelCopyBufferInfo { |
| 608 | buffer: &readback, |
| 609 | layout: wgpu::TexelCopyBufferLayout { |
| 610 | offset: 0, |
| 611 | bytes_per_row: Some(size[0] * 4), |
| 612 | rows_per_image: Some(size[1]), |
| 613 | }, |
| 614 | }, |
| 615 | wgpu::Extent3d { |
| 616 | width: size[0], |
| 617 | height: size[1], |
| 618 | depth_or_array_layers: 1, |
| 619 | }, |
| 620 | ); |
| 621 | renderer.queue().submit([encoder.finish()]); |
| 622 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); |
| 623 | renderer |
| 624 | .device() |
| 625 | .poll(wgpu::PollType::Wait { |
| 626 | submission_index: None, |
| 627 | timeout: Some(Duration::from_secs(5)), |
| 628 | }) |
| 629 | .unwrap(); |
| 630 | let pixels = readback.get_mapped_range(..).unwrap().to_vec(); |
| 631 | let pixel = |x: u32, y: u32| { |
| 632 | let offset = ((y * size[0] + x) * 4) as usize; |
| 633 | [pixels[offset], pixels[offset + 1], pixels[offset + 2]] |
| 634 | }; |
| 635 | for index in 0..icons.len() as u32 { |
| 636 | let painted = (4..28) |
| 637 | .flat_map(|y| (4 + 32 * index..28 + 32 * index).map(move |x| (x, y))) |
| 638 | .filter(|(x, y)| pixel(*x, *y).iter().any(|v| *v < 200)) |
| 639 | .count(); |
| 640 | assert!(painted > 20, "tag {index} was not painted"); |
| 641 | } |
| 642 | let count = |color: [u8; 3]| { |
| 643 | (40..64) |
| 644 | .flat_map(|y| (4..size[0]).map(move |x| (x, y))) |
| 645 | .filter(|(x, y)| { |
| 646 | pixel(*x, *y) |
| 647 | .iter() |
| 648 | .zip(color) |
| 649 | .all(|(v, c)| v.abs_diff(c) < 80) |
| 650 | }) |
| 651 | .count() |
| 652 | }; |
| 653 | assert!( |
| 654 | count([255, 0, 0]) > 50, |
| 655 | "text colour or underline was not painted" |
| 656 | ); |
| 657 | assert!( |
| 658 | count([0, 255, 0]) > 50, |
| 659 | "automatic text did not paint in the primitive's ink" |
| 660 | ); |
| 661 | assert!( |
| 662 | count([0, 0, 0]) > 10, |
| 663 | "automatic text on a highlight did not stay black" |
| 664 | ); |
| 665 | } |
| 666 | } |