1use std::fmt;
2use std::io::Write;
3
4use oxml_core::OxmlError;
5use oxml_core::raw_xml::{capture_element, capture_empty_element};
6use oxml_core::units::{Angle, Percent1000};
7use oxml_core::xml::{get_attr, local_name, matches_local_name};
8use quick_xml::events::{BytesEnd, BytesStart, Event};
9use quick_xml::{Reader, Writer};
10
11use crate::namespace::reject_conflicting_a_prefix;
12use crate::order::OrderedRawChildren;
13
14#[derive(Debug)]
16pub enum ColorError {
17 Xml(OxmlError),
18 InvalidRgb(String),
19 UnresolvedColor(String),
20 MissingAttribute { element: String, attribute: String },
21 UnexpectedElement(String),
22 InvalidTransformValue { element: String, value: String },
23}
24
25impl fmt::Display for ColorError {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::Xml(error) => error.fmt(formatter),
29 Self::InvalidRgb(value) => write!(
30 formatter,
31 "DrawingML RGB colour must be exactly six hexadecimal digits: {value}"
32 ),
33 Self::UnresolvedColor(value) => {
34 write!(formatter, "no concrete colour is available for: {value}")
35 }
36 Self::MissingAttribute { element, attribute } => {
37 write!(formatter, "DrawingML {element} requires @{attribute}")
38 }
39 Self::UnexpectedElement(element) => {
40 write!(formatter, "unexpected DrawingML colour element: {element}")
41 }
42 Self::InvalidTransformValue { element, value } => {
43 write!(formatter, "DrawingML {element} has invalid @val: {value}")
44 }
45 }
46 }
47}
48
49impl std::error::Error for ColorError {
50 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51 match self {
52 Self::Xml(error) => Some(error),
53 _ => None,
54 }
55 }
56}
57
58impl From<OxmlError> for ColorError {
59 fn from(error: OxmlError) -> Self {
60 Self::Xml(error)
61 }
62}
63
64pub type Result<T> = std::result::Result<T, ColorError>;
65
66#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub struct RgbColor([u8; 3]);
69
70impl RgbColor {
71 pub const fn new(red: u8, green: u8, blue: u8) -> Self {
73 Self([red, green, blue])
74 }
75
76 pub fn parse(value: &str) -> Result<Self> {
78 if value.len() != 6 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
79 return Err(ColorError::InvalidRgb(value.to_owned()));
80 }
81
82 Ok(Self([
83 parse_component(&value[0..2], value)?,
84 parse_component(&value[2..4], value)?,
85 parse_component(&value[4..6], value)?,
86 ]))
87 }
88
89 pub const fn components(self) -> [u8; 3] {
91 self.0
92 }
93}
94
95impl fmt::Display for RgbColor {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(
98 formatter,
99 "{:02X}{:02X}{:02X}",
100 self.0[0], self.0[1], self.0[2]
101 )
102 }
103}
104
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum ColorTransform {
108 Tint(Percent1000),
109 Shade(Percent1000),
110 Complement,
111 Inverse,
112 Gray,
113 Alpha(Percent1000),
114 AlphaOffset(Percent1000),
115 AlphaModulation(Percent1000),
116 Hue(Angle),
117 HueOffset(Angle),
118 HueModulation(Percent1000),
119 Saturation(Percent1000),
120 SaturationOffset(Percent1000),
121 SaturationModulation(Percent1000),
122 Luminance(Percent1000),
123 LuminanceOffset(Percent1000),
124 LuminanceModulation(Percent1000),
125 Red(Percent1000),
126 RedOffset(Percent1000),
127 RedModulation(Percent1000),
128 Green(Percent1000),
129 GreenOffset(Percent1000),
130 GreenModulation(Percent1000),
131 Blue(Percent1000),
132 BlueOffset(Percent1000),
133 BlueModulation(Percent1000),
134 Gamma,
135 InverseGamma,
136}
137
138impl ColorTransform {
139 fn from_xml(element: &BytesStart<'_>) -> Result<Option<Self>> {
140 let qualified_name = element.name();
141 let name = local_name(qualified_name.as_ref());
142 let transform = match name {
143 b"tint" => Self::Tint(parse_percent(element)?),
144 b"shade" => Self::Shade(parse_percent(element)?),
145 b"comp" => Self::Complement,
146 b"inv" => Self::Inverse,
147 b"gray" => Self::Gray,
148 b"alpha" => Self::Alpha(parse_percent(element)?),
149 b"alphaOff" => Self::AlphaOffset(parse_percent(element)?),
150 b"alphaMod" => Self::AlphaModulation(parse_percent(element)?),
151 b"hue" => Self::Hue(parse_angle(element)?),
152 b"hueOff" => Self::HueOffset(parse_angle(element)?),
153 b"hueMod" => Self::HueModulation(parse_percent(element)?),
154 b"sat" => Self::Saturation(parse_percent(element)?),
155 b"satOff" => Self::SaturationOffset(parse_percent(element)?),
156 b"satMod" => Self::SaturationModulation(parse_percent(element)?),
157 b"lum" => Self::Luminance(parse_percent(element)?),
158 b"lumOff" => Self::LuminanceOffset(parse_percent(element)?),
159 b"lumMod" => Self::LuminanceModulation(parse_percent(element)?),
160 b"red" => Self::Red(parse_percent(element)?),
161 b"redOff" => Self::RedOffset(parse_percent(element)?),
162 b"redMod" => Self::RedModulation(parse_percent(element)?),
163 b"green" => Self::Green(parse_percent(element)?),
164 b"greenOff" => Self::GreenOffset(parse_percent(element)?),
165 b"greenMod" => Self::GreenModulation(parse_percent(element)?),
166 b"blue" => Self::Blue(parse_percent(element)?),
167 b"blueOff" => Self::BlueOffset(parse_percent(element)?),
168 b"blueMod" => Self::BlueModulation(parse_percent(element)?),
169 b"gamma" => Self::Gamma,
170 b"invGamma" => Self::InverseGamma,
171 _ => return Ok(None),
172 };
173 Ok(Some(transform))
174 }
175
176 fn to_xml<W: Write>(self, writer: &mut Writer<W>) -> Result<()> {
177 let (name, value) = match self {
178 Self::Tint(value) => ("a:tint", Some(value.0)),
179 Self::Shade(value) => ("a:shade", Some(value.0)),
180 Self::Complement => ("a:comp", None),
181 Self::Inverse => ("a:inv", None),
182 Self::Gray => ("a:gray", None),
183 Self::Alpha(value) => ("a:alpha", Some(value.0)),
184 Self::AlphaOffset(value) => ("a:alphaOff", Some(value.0)),
185 Self::AlphaModulation(value) => ("a:alphaMod", Some(value.0)),
186 Self::Hue(value) => ("a:hue", Some(value.0)),
187 Self::HueOffset(value) => ("a:hueOff", Some(value.0)),
188 Self::HueModulation(value) => ("a:hueMod", Some(value.0)),
189 Self::Saturation(value) => ("a:sat", Some(value.0)),
190 Self::SaturationOffset(value) => ("a:satOff", Some(value.0)),
191 Self::SaturationModulation(value) => ("a:satMod", Some(value.0)),
192 Self::Luminance(value) => ("a:lum", Some(value.0)),
193 Self::LuminanceOffset(value) => ("a:lumOff", Some(value.0)),
194 Self::LuminanceModulation(value) => ("a:lumMod", Some(value.0)),
195 Self::Red(value) => ("a:red", Some(value.0)),
196 Self::RedOffset(value) => ("a:redOff", Some(value.0)),
197 Self::RedModulation(value) => ("a:redMod", Some(value.0)),
198 Self::Green(value) => ("a:green", Some(value.0)),
199 Self::GreenOffset(value) => ("a:greenOff", Some(value.0)),
200 Self::GreenModulation(value) => ("a:greenMod", Some(value.0)),
201 Self::Blue(value) => ("a:blue", Some(value.0)),
202 Self::BlueOffset(value) => ("a:blueOff", Some(value.0)),
203 Self::BlueModulation(value) => ("a:blueMod", Some(value.0)),
204 Self::Gamma => ("a:gamma", None),
205 Self::InverseGamma => ("a:invGamma", None),
206 };
207 let mut element = BytesStart::new(name);
208 let value_string = value.map(|raw| raw.to_string());
209 if let Some(value) = value_string.as_deref() {
210 element.push_attribute(("val", value));
211 }
212 writer
213 .write_event(Event::Empty(element))
214 .map_err(OxmlError::from)?;
215 Ok(())
216 }
217}
218
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub struct ResolvedColor {
222 pub red: u8,
223 pub green: u8,
224 pub blue: u8,
225 pub alpha: u8,
226}
227
228impl ResolvedColor {
229 pub const fn new(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
230 Self {
231 red,
232 green,
233 blue,
234 alpha,
235 }
236 }
237
238 pub const fn rgba(self) -> [u8; 4] {
239 [self.red, self.green, self.blue, self.alpha]
240 }
241}
242
243#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
245pub enum ColorMapSlot {
246 Background1,
247 Text1,
248 Background2,
249 Text2,
250 Accent1,
251 Accent2,
252 Accent3,
253 Accent4,
254 Accent5,
255 Accent6,
256 Hyperlink,
257 FollowedHyperlink,
258}
259
260impl ColorMapSlot {
261 fn from_value(value: &str) -> Option<Self> {
262 match value {
263 "bg1" => Some(Self::Background1),
264 "tx1" => Some(Self::Text1),
265 "bg2" => Some(Self::Background2),
266 "tx2" => Some(Self::Text2),
267 "accent1" => Some(Self::Accent1),
268 "accent2" => Some(Self::Accent2),
269 "accent3" => Some(Self::Accent3),
270 "accent4" => Some(Self::Accent4),
271 "accent5" => Some(Self::Accent5),
272 "accent6" => Some(Self::Accent6),
273 "hlink" => Some(Self::Hyperlink),
274 "folHlink" => Some(Self::FollowedHyperlink),
275 _ => None,
276 }
277 }
278
279 const fn index(self) -> usize {
280 match self {
281 Self::Background1 => 0,
282 Self::Text1 => 1,
283 Self::Background2 => 2,
284 Self::Text2 => 3,
285 Self::Accent1 => 4,
286 Self::Accent2 => 5,
287 Self::Accent3 => 6,
288 Self::Accent4 => 7,
289 Self::Accent5 => 8,
290 Self::Accent6 => 9,
291 Self::Hyperlink => 10,
292 Self::FollowedHyperlink => 11,
293 }
294 }
295}
296
297#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
299pub enum ThemeColorSlot {
300 Dark1,
301 Light1,
302 Dark2,
303 Light2,
304 Accent1,
305 Accent2,
306 Accent3,
307 Accent4,
308 Accent5,
309 Accent6,
310 Hyperlink,
311 FollowedHyperlink,
312}
313
314impl ThemeColorSlot {
315 pub const fn as_str(self) -> &'static str {
317 match self {
318 Self::Dark1 => "dk1",
319 Self::Light1 => "lt1",
320 Self::Dark2 => "dk2",
321 Self::Light2 => "lt2",
322 Self::Accent1 => "accent1",
323 Self::Accent2 => "accent2",
324 Self::Accent3 => "accent3",
325 Self::Accent4 => "accent4",
326 Self::Accent5 => "accent5",
327 Self::Accent6 => "accent6",
328 Self::Hyperlink => "hlink",
329 Self::FollowedHyperlink => "folHlink",
330 }
331 }
332}
333
334#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct ColorMap {
337 slots: [ThemeColorSlot; 12],
338}
339
340impl ColorMap {
341 #[allow(clippy::too_many_arguments)]
343 pub const fn new(
344 background1: ThemeColorSlot,
345 text1: ThemeColorSlot,
346 background2: ThemeColorSlot,
347 text2: ThemeColorSlot,
348 accent1: ThemeColorSlot,
349 accent2: ThemeColorSlot,
350 accent3: ThemeColorSlot,
351 accent4: ThemeColorSlot,
352 accent5: ThemeColorSlot,
353 accent6: ThemeColorSlot,
354 hyperlink: ThemeColorSlot,
355 followed_hyperlink: ThemeColorSlot,
356 ) -> Self {
357 Self {
358 slots: [
359 background1,
360 text1,
361 background2,
362 text2,
363 accent1,
364 accent2,
365 accent3,
366 accent4,
367 accent5,
368 accent6,
369 hyperlink,
370 followed_hyperlink,
371 ],
372 }
373 }
374
375 pub const fn theme_slot(&self, slot: ColorMapSlot) -> ThemeColorSlot {
377 self.slots[slot.index()]
378 }
379
380 pub fn with_overrides(&self, overrides: &[(ColorMapSlot, ThemeColorSlot)]) -> Self {
382 let mut resolved = self.clone();
383 for (source, destination) in overrides {
384 resolved.slots[source.index()] = *destination;
385 }
386 resolved
387 }
388
389 fn mapped_name<'a>(&self, value: &'a str) -> &'a str {
390 ColorMapSlot::from_value(value)
391 .map(|slot| self.theme_slot(slot).as_str())
392 .unwrap_or(value)
393 }
394}
395
396impl Default for ColorMap {
397 fn default() -> Self {
398 Self::new(
399 ThemeColorSlot::Light1,
400 ThemeColorSlot::Dark1,
401 ThemeColorSlot::Light2,
402 ThemeColorSlot::Dark2,
403 ThemeColorSlot::Accent1,
404 ThemeColorSlot::Accent2,
405 ThemeColorSlot::Accent3,
406 ThemeColorSlot::Accent4,
407 ThemeColorSlot::Accent5,
408 ThemeColorSlot::Accent6,
409 ThemeColorSlot::Hyperlink,
410 ThemeColorSlot::FollowedHyperlink,
411 )
412 }
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
417pub enum ColorChoice {
418 Srgb {
419 value: RgbColor,
420 transforms: Vec<ColorTransform>,
421 raw_children: OrderedRawChildren,
422 },
423 Scheme {
424 value: String,
425 transforms: Vec<ColorTransform>,
426 raw_children: OrderedRawChildren,
427 },
428 System {
429 value: String,
430 last_color: Option<RgbColor>,
431 transforms: Vec<ColorTransform>,
432 raw_children: OrderedRawChildren,
433 },
434 Preset {
435 value: String,
436 transforms: Vec<ColorTransform>,
437 raw_children: OrderedRawChildren,
438 },
439}
440
441impl ColorChoice {
442 pub fn srgb(value: RgbColor) -> Self {
444 Self::Srgb {
445 value,
446 transforms: Vec::new(),
447 raw_children: OrderedRawChildren::default(),
448 }
449 }
450
451 pub fn from_xml(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
453 reject_conflicting_a_prefix(start)?;
454 let qualified_name = start.name();
455 let element_name = local_name(qualified_name.as_ref());
456 let (transforms, raw_children) = capture_children(reader, element_name)?;
457 Self::from_parts(start, transforms, raw_children)
458 }
459
460 pub fn from_empty_xml(start: &BytesStart<'_>) -> Result<Self> {
462 reject_conflicting_a_prefix(start)?;
463 Self::from_parts(start, Vec::new(), OrderedRawChildren::default())
464 }
465
466 fn from_parts(
467 start: &BytesStart<'_>,
468 transforms: Vec<ColorTransform>,
469 raw_children: OrderedRawChildren,
470 ) -> Result<Self> {
471 let qualified_name = start.name();
472 let element_name = local_name(qualified_name.as_ref());
473 let value = required_attr(start, b"val")?;
474 match element_name {
475 b"srgbClr" => Ok(Self::Srgb {
476 value: RgbColor::parse(&value)?,
477 transforms,
478 raw_children,
479 }),
480 b"schemeClr" => Ok(Self::Scheme {
481 value,
482 transforms,
483 raw_children,
484 }),
485 b"sysClr" => Ok(Self::System {
486 value,
487 last_color: get_attr(start, b"lastClr")
488 .map(|last| RgbColor::parse(&last))
489 .transpose()?,
490 transforms,
491 raw_children,
492 }),
493 b"prstClr" => Ok(Self::Preset {
494 value,
495 transforms,
496 raw_children,
497 }),
498 _ => Err(ColorError::UnexpectedElement(
499 String::from_utf8_lossy(start.name().as_ref()).into_owned(),
500 )),
501 }
502 }
503
504 pub fn to_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
506 let (tag, value, last_color, transforms, raw_children) = match self {
507 Self::Srgb {
508 value,
509 transforms,
510 raw_children,
511 } => (
512 "a:srgbClr",
513 value.to_string(),
514 None,
515 transforms,
516 raw_children,
517 ),
518 Self::Scheme {
519 value,
520 transforms,
521 raw_children,
522 } => ("a:schemeClr", value.clone(), None, transforms, raw_children),
523 Self::System {
524 value,
525 last_color,
526 transforms,
527 raw_children,
528 } => (
529 "a:sysClr",
530 value.clone(),
531 last_color.map(|colour| colour.to_string()),
532 transforms,
533 raw_children,
534 ),
535 Self::Preset {
536 value,
537 transforms,
538 raw_children,
539 } => ("a:prstClr", value.clone(), None, transforms, raw_children),
540 };
541
542 let mut start = BytesStart::new(tag);
543 start.push_attribute(("val", value.as_str()));
544 if let Some(last_color) = last_color.as_deref() {
545 start.push_attribute(("lastClr", last_color));
546 }
547
548 if raw_children.is_empty() && transforms.is_empty() {
549 writer
550 .write_event(Event::Empty(start))
551 .map_err(OxmlError::from)?;
552 return Ok(());
553 }
554
555 writer
556 .write_event(Event::Start(start))
557 .map_err(OxmlError::from)?;
558 for boundary in 0..=transforms.len() {
559 for raw in raw_children.at(boundary) {
560 writer.get_mut().write_all(raw).map_err(OxmlError::from)?;
561 }
562 if let Some(transform) = transforms.get(boundary) {
563 transform.to_xml(writer)?;
564 }
565 }
566 writer
567 .write_event(Event::End(BytesEnd::new(tag)))
568 .map_err(OxmlError::from)?;
569 Ok(())
570 }
571
572 pub fn raw_children(&self) -> &OrderedRawChildren {
574 match self {
575 Self::Srgb { raw_children, .. }
576 | Self::Scheme { raw_children, .. }
577 | Self::System { raw_children, .. }
578 | Self::Preset { raw_children, .. } => raw_children,
579 }
580 }
581
582 pub fn transforms(&self) -> &[ColorTransform] {
584 match self {
585 Self::Srgb { transforms, .. }
586 | Self::Scheme { transforms, .. }
587 | Self::System { transforms, .. }
588 | Self::Preset { transforms, .. } => transforms,
589 }
590 }
591}
592
593pub fn resolve_color(
599 choice: &ColorChoice,
600 color_map: &ColorMap,
601 lookup: &[(&str, RgbColor)],
602) -> Result<ResolvedColor> {
603 let find = |name: &str| {
604 lookup
605 .iter()
606 .find_map(|(candidate, colour)| (*candidate == name).then_some(*colour))
607 };
608 let (base, transforms) = match choice {
609 ColorChoice::Srgb {
610 value, transforms, ..
611 } => (*value, transforms.as_slice()),
612 ColorChoice::Scheme {
613 value, transforms, ..
614 } => {
615 let mapped = color_map.mapped_name(value);
616 (
617 find(mapped).ok_or_else(|| ColorError::UnresolvedColor(mapped.to_owned()))?,
618 transforms.as_slice(),
619 )
620 }
621 ColorChoice::System {
622 value,
623 last_color,
624 transforms,
625 ..
626 } => (
627 find(value)
628 .or(*last_color)
629 .ok_or_else(|| ColorError::UnresolvedColor(value.clone()))?,
630 transforms.as_slice(),
631 ),
632 ColorChoice::Preset {
633 value, transforms, ..
634 } => (
635 find(value).ok_or_else(|| ColorError::UnresolvedColor(value.clone()))?,
636 transforms.as_slice(),
637 ),
638 };
639 Ok(apply_color_transforms(base, transforms))
640}
641
642fn required_attr(element: &BytesStart<'_>, name: &[u8]) -> Result<String> {
643 get_attr(element, name).ok_or_else(|| ColorError::MissingAttribute {
644 element: String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned(),
645 attribute: String::from_utf8_lossy(name).into_owned(),
646 })
647}
648
649fn parse_component(component: &str, full_value: &str) -> Result<u8> {
650 u8::from_str_radix(component, 16).map_err(|_| ColorError::InvalidRgb(full_value.to_owned()))
651}
652
653fn parse_percent(element: &BytesStart<'_>) -> Result<Percent1000> {
654 parse_transform_i32(element).map(Percent1000)
655}
656
657fn parse_angle(element: &BytesStart<'_>) -> Result<Angle> {
658 parse_transform_i32(element).map(Angle)
659}
660
661fn parse_transform_i32(element: &BytesStart<'_>) -> Result<i32> {
662 let value = required_attr(element, b"val")?;
663 value
664 .parse()
665 .map_err(|_| ColorError::InvalidTransformValue {
666 element: String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned(),
667 value,
668 })
669}
670
671fn capture_children(
672 reader: &mut Reader<&[u8]>,
673 end_name: &[u8],
674) -> Result<(Vec<ColorTransform>, OrderedRawChildren)> {
675 let mut transforms = Vec::new();
676 let mut raw_children = OrderedRawChildren::default();
677 let mut buffer = Vec::new();
678
679 loop {
680 match reader
681 .read_event_into(&mut buffer)
682 .map_err(OxmlError::from)?
683 {
684 Event::Start(element) => {
685 let transform = ColorTransform::from_xml(&element)?;
686 let raw = capture_element(reader, &element)?;
687 if let Some(transform) = transform.filter(|_| is_explicit_empty_element(&raw)) {
688 reject_conflicting_a_prefix(&element)?;
689 transforms.push(transform);
690 } else {
691 raw_children.push(transforms.len(), raw);
692 }
693 }
694 Event::Empty(element) => {
695 if let Some(transform) = ColorTransform::from_xml(&element)? {
696 reject_conflicting_a_prefix(&element)?;
697 transforms.push(transform);
698 } else {
699 raw_children.push(transforms.len(), capture_empty_element(&element)?);
700 }
701 }
702 Event::End(element) if matches_local_name(element.name().as_ref(), end_name) => break,
703 Event::Eof => {
704 return Err(ColorError::Xml(OxmlError::MissingElement(format!(
705 "closing {} colour element",
706 String::from_utf8_lossy(end_name)
707 ))));
708 }
709 _ => {}
710 }
711 buffer.clear();
712 }
713
714 Ok((transforms, raw_children))
715}
716
717fn is_explicit_empty_element(xml: &[u8]) -> bool {
718 let mut reader = Reader::from_reader(xml);
719 let mut buffer = Vec::new();
720 if !matches!(reader.read_event_into(&mut buffer), Ok(Event::Start(_))) {
721 return false;
722 }
723 loop {
724 buffer.clear();
725 match reader.read_event_into(&mut buffer) {
726 Ok(Event::Text(text)) if is_xml_whitespace(text.as_ref()) => {}
727 Ok(Event::CData(text)) if is_xml_whitespace(text.as_ref()) => {}
728 Ok(Event::Comment(_) | Event::PI(_)) => {}
729 Ok(Event::End(_)) => {
730 buffer.clear();
731 return matches!(reader.read_event_into(&mut buffer), Ok(Event::Eof));
732 }
733 _ => return false,
734 }
735 }
736}
737
738fn is_xml_whitespace(bytes: &[u8]) -> bool {
739 bytes
740 .iter()
741 .all(|byte| matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
742}
743
744pub fn apply_color_transforms(colour: RgbColor, transforms: &[ColorTransform]) -> ResolvedColor {
746 let [red, green, blue] = colour.components();
747 let mut current = WorkingColor {
748 red: red as f64 / 255.0,
749 green: green as f64 / 255.0,
750 blue: blue as f64 / 255.0,
751 alpha: 1.0,
752 };
753 for transform in transforms {
754 current.apply(*transform);
755 }
756 current.resolved()
757}
758
759pub fn apply_tint_shade_pct(
761 hex: &str,
762 tint: Option<Percent1000>,
763 shade: Option<Percent1000>,
764) -> String {
765 let Ok(colour) = RgbColor::parse(hex) else {
766 return hex.to_owned();
767 };
768 let mut transforms = Vec::with_capacity(2);
769 if let Some(value) = tint {
770 transforms.push(ColorTransform::Tint(value));
771 }
772 if let Some(value) = shade {
773 transforms.push(ColorTransform::Shade(value));
774 }
775 let resolved = apply_color_transforms(colour, &transforms);
776 RgbColor::new(resolved.red, resolved.green, resolved.blue).to_string()
777}
778
779pub fn apply_lum_mod_off(
781 hex: &str,
782 lum_mod: Option<Percent1000>,
783 lum_off: Option<Percent1000>,
784) -> String {
785 let Ok(colour) = RgbColor::parse(hex) else {
786 return hex.to_owned();
787 };
788 let mut transforms = Vec::with_capacity(2);
789 if let Some(value) = lum_mod {
790 transforms.push(ColorTransform::LuminanceModulation(value));
791 }
792 if let Some(value) = lum_off {
793 transforms.push(ColorTransform::LuminanceOffset(value));
794 }
795 let resolved = apply_color_transforms(colour, &transforms);
796 RgbColor::new(resolved.red, resolved.green, resolved.blue).to_string()
797}
798
799pub fn srgb_to_linear(channel: f64) -> f64 {
801 if channel <= 0.04045 {
802 channel / 12.92
803 } else {
804 ((channel + 0.055) / 1.055).powf(2.4)
805 }
806}
807
808pub fn linear_to_srgb(channel: f64) -> f64 {
810 if channel <= 0.003_130_8 {
811 channel * 12.92
812 } else {
813 1.055 * channel.powf(1.0 / 2.4) - 0.055
814 }
815}
816
817#[derive(Clone, Copy)]
818struct WorkingColor {
819 red: f64,
820 green: f64,
821 blue: f64,
822 alpha: f64,
823}
824
825impl WorkingColor {
826 fn apply(&mut self, transform: ColorTransform) {
827 match transform {
828 ColorTransform::Tint(value) => {
829 let amount = percent(value);
830 self.map_linear(|channel| channel * amount + 1.0 - amount);
831 }
832 ColorTransform::Shade(value) => {
833 let amount = percent(value);
834 self.map_linear(|channel| channel * amount);
835 }
836 ColorTransform::Complement => {
837 self.map_hsl(|hue, sat, lum| ((hue + 0.5).rem_euclid(1.0), sat, lum))
838 }
839 ColorTransform::Inverse => {
840 self.map_linear(|channel| 1.0 - channel);
841 }
842 ColorTransform::Gray => {
843 let luminance = 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue;
844 self.red = luminance;
845 self.green = luminance;
846 self.blue = luminance;
847 }
848 ColorTransform::Alpha(value) => self.alpha = percent(value),
849 ColorTransform::AlphaOffset(value) => self.alpha += percent(value),
850 ColorTransform::AlphaModulation(value) => self.alpha *= percent(value),
851 ColorTransform::Hue(value) => {
852 let hue = angle_turns(value);
853 self.map_hsl(|_, sat, lum| (hue, sat, lum));
854 }
855 ColorTransform::HueOffset(value) => {
856 let offset = angle_turns(value);
857 self.map_hsl(|hue, sat, lum| (hue + offset, sat, lum));
858 }
859 ColorTransform::HueModulation(value) => {
860 let amount = percent(value);
861 self.map_hsl(|hue, sat, lum| (hue * amount, sat, lum));
862 }
863 ColorTransform::Saturation(value) => {
864 let value = percent(value);
865 self.map_hsl(|hue, _, lum| (hue, value, lum));
866 }
867 ColorTransform::SaturationOffset(value) => {
868 let offset = percent(value);
869 self.map_hsl(|hue, sat, lum| (hue, sat + offset, lum));
870 }
871 ColorTransform::SaturationModulation(value) => {
872 let amount = percent(value);
873 self.map_hsl(|hue, sat, lum| (hue, sat * amount, lum));
874 }
875 ColorTransform::Luminance(value) => {
876 let value = percent(value);
877 self.map_hsl(|hue, sat, _| (hue, sat, value));
878 }
879 ColorTransform::LuminanceOffset(value) => {
880 let offset = percent(value);
881 self.map_hsl(|hue, sat, lum| (hue, sat, lum + offset));
882 }
883 ColorTransform::LuminanceModulation(value) => {
884 let amount = percent(value);
885 self.map_hsl(|hue, sat, lum| (hue, sat, lum * amount));
886 }
887 ColorTransform::Red(value) => self.red = linear_to_srgb(percent(value)),
888 ColorTransform::RedOffset(value) => {
889 self.red = linear_to_srgb(srgb_to_linear(self.red) + percent(value));
890 }
891 ColorTransform::RedModulation(value) => {
892 self.red = linear_to_srgb(srgb_to_linear(self.red) * percent(value));
893 }
894 ColorTransform::Green(value) => self.green = linear_to_srgb(percent(value)),
895 ColorTransform::GreenOffset(value) => {
896 self.green = linear_to_srgb(srgb_to_linear(self.green) + percent(value));
897 }
898 ColorTransform::GreenModulation(value) => {
899 self.green = linear_to_srgb(srgb_to_linear(self.green) * percent(value));
900 }
901 ColorTransform::Blue(value) => self.blue = linear_to_srgb(percent(value)),
902 ColorTransform::BlueOffset(value) => {
903 self.blue = linear_to_srgb(srgb_to_linear(self.blue) + percent(value));
904 }
905 ColorTransform::BlueModulation(value) => {
906 self.blue = linear_to_srgb(srgb_to_linear(self.blue) * percent(value));
907 }
908 ColorTransform::Gamma => {
909 self.red = linear_to_srgb(self.red);
910 self.green = linear_to_srgb(self.green);
911 self.blue = linear_to_srgb(self.blue);
912 }
913 ColorTransform::InverseGamma => {
914 self.red = srgb_to_linear(self.red);
915 self.green = srgb_to_linear(self.green);
916 self.blue = srgb_to_linear(self.blue);
917 }
918 }
919 self.clamp();
920 }
921
922 fn map_linear(&mut self, transform: impl Fn(f64) -> f64) {
923 self.red = linear_to_srgb(transform(srgb_to_linear(self.red)));
924 self.green = linear_to_srgb(transform(srgb_to_linear(self.green)));
925 self.blue = linear_to_srgb(transform(srgb_to_linear(self.blue)));
926 }
927
928 fn map_hsl(&mut self, transform: impl Fn(f64, f64, f64) -> (f64, f64, f64)) {
929 let (hue, sat, lum) = rgb_to_hsl(self.red, self.green, self.blue);
930 let (hue, sat, lum) = transform(hue, sat, lum);
931 (self.red, self.green, self.blue) = hsl_to_rgb(
932 hue.rem_euclid(1.0),
933 sat.clamp(0.0, 1.0),
934 lum.clamp(0.0, 1.0),
935 );
936 }
937
938 fn clamp(&mut self) {
939 self.red = self.red.clamp(0.0, 1.0);
940 self.green = self.green.clamp(0.0, 1.0);
941 self.blue = self.blue.clamp(0.0, 1.0);
942 self.alpha = self.alpha.clamp(0.0, 1.0);
943 }
944
945 fn resolved(self) -> ResolvedColor {
946 let alpha = channel_byte(self.alpha);
947 if alpha == 0 {
948 return ResolvedColor::new(0, 0, 0, 0);
949 }
950 let red = alpha_quantized_channel(channel_byte(self.red), alpha);
951 let green = alpha_quantized_channel(channel_byte(self.green), alpha);
952 let blue = alpha_quantized_channel(channel_byte(self.blue), alpha);
953 ResolvedColor::new(red, green, blue, alpha)
954 }
955}
956
957fn percent(value: Percent1000) -> f64 {
958 value.to_fraction()
959}
960
961fn angle_turns(value: Angle) -> f64 {
962 value.to_degrees() / 360.0
963}
964
965fn channel_byte(channel: f64) -> u8 {
966 (channel.clamp(0.0, 1.0) * 255.0 + 1e-9).round() as u8
967}
968
969fn alpha_quantized_channel(channel: u8, alpha: u8) -> u8 {
970 if alpha == u8::MAX {
971 return channel;
972 }
973 let channel = u16::from(channel);
974 let alpha = u16::from(alpha);
975 let premultiplied = (channel * alpha + 127) / 255;
976 ((premultiplied * 255 + alpha / 2) / alpha) as u8
977}
978
979fn rgb_to_hsl(red: f64, green: f64, blue: f64) -> (f64, f64, f64) {
980 let max = red.max(green).max(blue);
981 let min = red.min(green).min(blue);
982 let lum = (max + min) / 2.0;
983 if (max - min).abs() <= f64::EPSILON {
984 return (0.0, 0.0, lum);
985 }
986
987 let delta = max - min;
988 let sat = if lum > 0.5 {
989 delta / (2.0 - max - min)
990 } else {
991 delta / (max + min)
992 };
993 let hue = if (max - red).abs() <= f64::EPSILON {
994 (green - blue) / delta + if green < blue { 6.0 } else { 0.0 }
995 } else if (max - green).abs() <= f64::EPSILON {
996 (blue - red) / delta + 2.0
997 } else {
998 (red - green) / delta + 4.0
999 } / 6.0;
1000 (hue, sat, lum)
1001}
1002
1003fn hsl_to_rgb(hue: f64, sat: f64, lum: f64) -> (f64, f64, f64) {
1004 if sat <= f64::EPSILON {
1005 return (lum, lum, lum);
1006 }
1007 let q = if lum < 0.5 {
1008 lum * (1.0 + sat)
1009 } else {
1010 lum + sat - lum * sat
1011 };
1012 let p = 2.0 * lum - q;
1013 (
1014 hue_to_rgb(p, q, hue + 1.0 / 3.0),
1015 hue_to_rgb(p, q, hue),
1016 hue_to_rgb(p, q, hue - 1.0 / 3.0),
1017 )
1018}
1019
1020fn hue_to_rgb(p: f64, q: f64, hue: f64) -> f64 {
1021 let hue = hue.rem_euclid(1.0);
1022 if hue < 1.0 / 6.0 {
1023 p + (q - p) * 6.0 * hue
1024 } else if hue < 0.5 {
1025 q
1026 } else if hue < 2.0 / 3.0 {
1027 p + (q - p) * (2.0 / 3.0 - hue) * 6.0
1028 } else {
1029 p
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use std::fs;
1036 use std::path::{Path, PathBuf};
1037 use std::process::Command;
1038
1039 use oxml_core::units::{Angle, Percent1000};
1040 use oxml_opc::OpcPackage;
1041 use oxml_opc::relationship::rel_types;
1042 use quick_xml::events::Event;
1043
1044 use super::{
1045 ColorChoice, ColorError, ColorMap, ColorMapSlot, ColorTransform, ResolvedColor, RgbColor,
1046 ThemeColorSlot, apply_color_transforms, linear_to_srgb, resolve_color, srgb_to_linear,
1047 };
1048 use crate::order::OrderedRawChildren;
1049 use quick_xml::{Reader, Writer};
1050
1051 const POWERPOINT_ORACLE_VERSION: &str = "16.104";
1052 const POWERPOINT_ORACLE_BUILD: &str = "16.104.25121423";
1053 const POWERPOINT_ORACLE_APP_BUILD: &str = "1214";
1054
1055 struct OracleCase {
1056 name: &'static str,
1057 input: RgbColor,
1058 transforms: &'static [ColorTransform],
1059 expected: [u8; 4],
1060 }
1061
1062 const ORACLE_CASES: &[OracleCase] = &[
1063 oracle_case(
1064 "single_tint",
1065 0x1F497D,
1066 &[ColorTransform::Tint(Percent1000(62_000))],
1067 [167, 174, 189, 255],
1068 ),
1069 oracle_case(
1070 "single_shade",
1071 0xEEECE1,
1072 &[ColorTransform::Shade(Percent1000(58_000))],
1073 [187, 185, 176, 255],
1074 ),
1075 oracle_case(
1076 "single_comp",
1077 0x4F81BD,
1078 &[ColorTransform::Complement],
1079 [189, 139, 79, 255],
1080 ),
1081 oracle_case(
1082 "single_inv",
1083 0xC0504D,
1084 &[ColorTransform::Inverse],
1085 [183, 246, 246, 255],
1086 ),
1087 oracle_case(
1088 "single_gray",
1089 0x9BBB59,
1090 &[ColorTransform::Gray],
1091 [173, 173, 173, 255],
1092 ),
1093 oracle_case(
1094 "single_alpha",
1095 0x8064A2,
1096 &[ColorTransform::Alpha(Percent1000(47_000))],
1097 [128, 100, 162, 120],
1098 ),
1099 oracle_case(
1100 "single_alpha_off",
1101 0x4BACC6,
1102 &[ColorTransform::AlphaOffset(Percent1000(-30_000))],
1103 [76, 172, 198, 179],
1104 ),
1105 oracle_case(
1106 "single_alpha_mod",
1107 0xF79646,
1108 &[ColorTransform::AlphaModulation(Percent1000(43_000))],
1109 [248, 151, 70, 110],
1110 ),
1111 oracle_case(
1112 "single_hue",
1113 0x1F497D,
1114 &[ColorTransform::Hue(Angle(9_000_000))],
1115 [31, 125, 78, 255],
1116 ),
1117 oracle_case(
1118 "single_hue_off",
1119 0xEEECE1,
1120 &[ColorTransform::HueOffset(Angle(-3_000_000))],
1121 [238, 225, 225, 255],
1122 ),
1123 oracle_case(
1124 "single_hue_mod",
1125 0x4F81BD,
1126 &[ColorTransform::HueModulation(Percent1000(55_000))],
1127 [85, 189, 79, 255],
1128 ),
1129 oracle_case(
1130 "single_sat",
1131 0xC0504D,
1132 &[ColorTransform::Saturation(Percent1000(72_000))],
1133 [221, 52, 48, 255],
1134 ),
1135 oracle_case(
1136 "single_sat_off",
1137 0x9BBB59,
1138 &[ColorTransform::SaturationOffset(Percent1000(-25_000))],
1139 [145, 158, 118, 255],
1140 ),
1141 oracle_case(
1142 "single_sat_mod",
1143 0x8064A2,
1144 &[ColorTransform::SaturationModulation(Percent1000(45_000))],
1145 [130, 117, 145, 255],
1146 ),
1147 oracle_case(
1148 "single_lum",
1149 0x4BACC6,
1150 &[ColorTransform::Luminance(Percent1000(65_000))],
1151 [119, 192, 212, 255],
1152 ),
1153 oracle_case(
1154 "single_lum_off",
1155 0xF79646,
1156 &[ColorTransform::LuminanceOffset(Percent1000(20_000))],
1157 [251, 205, 168, 255],
1158 ),
1159 oracle_case(
1160 "single_lum_mod",
1161 0x1F497D,
1162 &[ColorTransform::LuminanceModulation(Percent1000(55_000))],
1163 [17, 40, 69, 255],
1164 ),
1165 oracle_case(
1166 "single_red",
1167 0xEEECE1,
1168 &[ColorTransform::Red(Percent1000(20_000))],
1169 [124, 236, 225, 255],
1170 ),
1171 oracle_case(
1172 "single_red_off",
1173 0x4F81BD,
1174 &[ColorTransform::RedOffset(Percent1000(35_000))],
1175 [175, 129, 189, 255],
1176 ),
1177 oracle_case(
1178 "single_red_mod",
1179 0xC0504D,
1180 &[ColorTransform::RedModulation(Percent1000(40_000))],
1181 [127, 80, 77, 255],
1182 ),
1183 oracle_case(
1184 "single_green",
1185 0x9BBB59,
1186 &[ColorTransform::Green(Percent1000(70_000))],
1187 [155, 218, 89, 255],
1188 ),
1189 oracle_case(
1190 "single_green_off",
1191 0x8064A2,
1192 &[ColorTransform::GreenOffset(Percent1000(-20_000))],
1193 [128, 0, 162, 255],
1194 ),
1195 oracle_case(
1196 "single_green_mod",
1197 0x4BACC6,
1198 &[ColorTransform::GreenModulation(Percent1000(140_000))],
1199 [75, 200, 198, 255],
1200 ),
1201 oracle_case(
1202 "single_blue",
1203 0xF79646,
1204 &[ColorTransform::Blue(Percent1000(85_000))],
1205 [247, 150, 237, 255],
1206 ),
1207 oracle_case(
1208 "single_blue_off",
1209 0x1F497D,
1210 &[ColorTransform::BlueOffset(Percent1000(25_000))],
1211 [31, 73, 180, 255],
1212 ),
1213 oracle_case(
1214 "single_blue_mod",
1215 0xEEECE1,
1216 &[ColorTransform::BlueModulation(Percent1000(35_000))],
1217 [238, 236, 140, 255],
1218 ),
1219 oracle_case(
1220 "single_gamma",
1221 0x4F81BD,
1222 &[ColorTransform::Gamma],
1223 [151, 189, 223, 255],
1224 ),
1225 oracle_case(
1226 "single_inv_gamma",
1227 0xC0504D,
1228 &[ColorTransform::InverseGamma],
1229 [134, 20, 19, 255],
1230 ),
1231 oracle_case(
1232 "stack_red_off_then_mod",
1233 0x9BBB59,
1234 &[
1235 ColorTransform::RedOffset(Percent1000(40_000)),
1236 ColorTransform::RedModulation(Percent1000(50_000)),
1237 ],
1238 [163, 187, 89, 255],
1239 ),
1240 oracle_case(
1241 "stack_red_mod_then_off",
1242 0x8064A2,
1243 &[
1244 ColorTransform::RedModulation(Percent1000(50_000)),
1245 ColorTransform::RedOffset(Percent1000(40_000)),
1246 ],
1247 [189, 100, 162, 255],
1248 ),
1249 oracle_case(
1250 "stack_alpha_clamp_high",
1251 0x4BACC6,
1252 &[
1253 ColorTransform::Alpha(Percent1000(75_000)),
1254 ColorTransform::AlphaOffset(Percent1000(80_000)),
1255 ],
1256 [75, 172, 198, 255],
1257 ),
1258 oracle_case(
1259 "stack_alpha_clamp_low",
1260 0xF79646,
1261 &[
1262 ColorTransform::Alpha(Percent1000(25_000)),
1263 ColorTransform::AlphaOffset(Percent1000(-80_000)),
1264 ],
1265 [0, 0, 0, 0],
1266 ),
1267 oracle_case(
1268 "stack_lum_mod_then_off",
1269 0x1F497D,
1270 &[
1271 ColorTransform::LuminanceModulation(Percent1000(60_000)),
1272 ColorTransform::LuminanceOffset(Percent1000(25_000)),
1273 ],
1274 [44, 103, 177, 255],
1275 ),
1276 oracle_case(
1277 "stack_lum_off_then_mod",
1278 0xEEECE1,
1279 &[
1280 ColorTransform::LuminanceOffset(Percent1000(25_000)),
1281 ColorTransform::LuminanceModulation(Percent1000(60_000)),
1282 ],
1283 [153, 153, 153, 255],
1284 ),
1285 oracle_case(
1286 "stack_hsl",
1287 0x4F81BD,
1288 &[
1289 ColorTransform::HueOffset(Angle(4_200_000)),
1290 ColorTransform::SaturationModulation(Percent1000(65_000)),
1291 ColorTransform::LuminanceModulation(Percent1000(80_000)),
1292 ],
1293 [121, 76, 139, 255],
1294 ),
1295 oracle_case(
1296 "stack_tint_then_shade",
1297 0xC0504D,
1298 &[
1299 ColorTransform::Tint(Percent1000(60_000)),
1300 ColorTransform::Shade(Percent1000(70_000)),
1301 ],
1302 [188, 152, 151, 255],
1303 ),
1304 oracle_case(
1305 "stack_shade_then_tint",
1306 0x9BBB59,
1307 &[
1308 ColorTransform::Shade(Percent1000(70_000)),
1309 ColorTransform::Tint(Percent1000(60_000)),
1310 ],
1311 [194, 205, 177, 255],
1312 ),
1313 oracle_case(
1314 "stack_linear_tint",
1315 0x8064A2,
1316 &[
1317 ColorTransform::InverseGamma,
1318 ColorTransform::Tint(Percent1000(55_000)),
1319 ColorTransform::Gamma,
1320 ],
1321 [220, 219, 223, 255],
1322 ),
1323 oracle_case(
1324 "stack_gamma_shade",
1325 0x4BACC6,
1326 &[
1327 ColorTransform::Gamma,
1328 ColorTransform::Shade(Percent1000(55_000)),
1329 ColorTransform::InverseGamma,
1330 ],
1331 [41, 95, 109, 255],
1332 ),
1333 oracle_case(
1334 "stack_comp_hue_gray",
1335 0xF79646,
1336 &[
1337 ColorTransform::Complement,
1338 ColorTransform::HueOffset(Angle(-2_400_000)),
1339 ColorTransform::Gray,
1340 ],
1341 [207, 207, 207, 255],
1342 ),
1343 ];
1344
1345 const fn oracle_case(
1346 name: &'static str,
1347 input: u32,
1348 transforms: &'static [ColorTransform],
1349 expected: [u8; 4],
1350 ) -> OracleCase {
1351 OracleCase {
1352 name,
1353 input: RgbColor::new(
1354 ((input >> 16) & 0xff) as u8,
1355 ((input >> 8) & 0xff) as u8,
1356 (input & 0xff) as u8,
1357 ),
1358 transforms,
1359 expected,
1360 }
1361 }
1362
1363 fn parse(xml: &[u8]) -> ColorChoice {
1364 let mut reader = Reader::from_reader(xml);
1365 let mut buffer = Vec::new();
1366 match reader.read_event_into(&mut buffer).unwrap() {
1367 Event::Start(element) => ColorChoice::from_xml(&mut reader, &element).unwrap(),
1368 Event::Empty(element) => ColorChoice::from_empty_xml(&element).unwrap(),
1369 event => panic!("expected colour element, got {event:?}"),
1370 }
1371 }
1372
1373 fn write(colour: &ColorChoice) -> Vec<u8> {
1374 let mut writer = Writer::new(Vec::new());
1375 colour.to_xml(&mut writer).unwrap();
1376 writer.into_inner()
1377 }
1378
1379 #[test]
1380 fn srgb_colour_parses_and_round_trips() {
1381 let colour = parse(br#"<x:srgbClr val="12ABef"/>"#);
1382 assert_eq!(
1383 colour,
1384 ColorChoice::Srgb {
1385 value: RgbColor::new(0x12, 0xAB, 0xEF),
1386 transforms: Vec::new(),
1387 raw_children: OrderedRawChildren::default(),
1388 }
1389 );
1390 assert_eq!(write(&colour), br#"<a:srgbClr val="12ABEF"/>"#);
1391 assert_eq!(ColorChoice::srgb(RgbColor::new(0x12, 0xAB, 0xEF)), colour);
1392 }
1393
1394 #[test]
1395 fn scheme_colour_parses_and_round_trips() {
1396 let colour = parse(br#"<x:schemeClr val="accent2"/>"#);
1397 assert_eq!(write(&colour), br#"<a:schemeClr val="accent2"/>"#);
1398 }
1399
1400 #[test]
1401 fn system_colour_uses_and_preserves_last_colour() {
1402 let colour = parse(br#"<x:sysClr val="windowText" lastClr="102030"/>"#);
1403 assert_eq!(
1404 write(&colour),
1405 br#"<a:sysClr val="windowText" lastClr="102030"/>"#
1406 );
1407 }
1408
1409 #[test]
1410 fn system_colour_without_last_colour_round_trips() {
1411 let colour = parse(br#"<x:sysClr val="windowText"/>"#);
1412 assert_eq!(write(&colour), br#"<a:sysClr val="windowText"/>"#);
1413 }
1414
1415 #[test]
1416 fn preset_colour_parses_and_round_trips() {
1417 let colour = parse(br#"<x:prstClr val="aliceBlue"/>"#);
1418 assert_eq!(write(&colour), br#"<a:prstClr val="aliceBlue"/>"#);
1419 }
1420
1421 #[test]
1422 fn unknown_colour_children_are_preserved_in_place() {
1423 let input = br#"<x:schemeClr val="accent2"><z:first z:id="1"/><z:second><z:leaf>one & two</z:leaf></z:second></x:schemeClr>"#;
1424 let colour = parse(input);
1425
1426 assert_eq!(
1427 colour.raw_children().at(0).collect::<Vec<_>>(),
1428 vec![
1429 br#"<z:first z:id="1"/>"#.as_slice(),
1430 br#"<z:second><z:leaf>one & two</z:leaf></z:second>"#.as_slice(),
1431 ]
1432 );
1433 assert_eq!(
1434 write(&colour),
1435 br#"<a:schemeClr val="accent2"><z:first z:id="1"/><z:second><z:leaf>one & two</z:leaf></z:second></a:schemeClr>"#
1436 );
1437 }
1438
1439 #[test]
1440 fn malformed_srgb_values_are_rejected() {
1441 assert!(matches!(
1442 RgbColor::parse("12345"),
1443 Err(ColorError::InvalidRgb(value)) if value == "12345"
1444 ));
1445 assert!(matches!(
1446 RgbColor::parse("GG0000"),
1447 Err(ColorError::InvalidRgb(value)) if value == "GG0000"
1448 ));
1449 }
1450
1451 #[test]
1452 fn malformed_system_fallback_is_rejected() {
1453 let xml = br#"<a:sysClr val="window" lastClr="12345"/>"#;
1454 let mut reader = Reader::from_reader(xml.as_slice());
1455 let mut buffer = Vec::new();
1456 let Event::Empty(element) = reader.read_event_into(&mut buffer).unwrap() else {
1457 panic!("expected empty system colour");
1458 };
1459 assert!(ColorChoice::from_empty_xml(&element).is_err());
1460 }
1461
1462 #[test]
1463 fn standard_colour_map_uses_office_theme_slots() {
1464 let map = ColorMap::default();
1465 let expected = [
1466 (ColorMapSlot::Background1, ThemeColorSlot::Light1),
1467 (ColorMapSlot::Text1, ThemeColorSlot::Dark1),
1468 (ColorMapSlot::Background2, ThemeColorSlot::Light2),
1469 (ColorMapSlot::Text2, ThemeColorSlot::Dark2),
1470 (ColorMapSlot::Accent1, ThemeColorSlot::Accent1),
1471 (ColorMapSlot::Accent2, ThemeColorSlot::Accent2),
1472 (ColorMapSlot::Accent3, ThemeColorSlot::Accent3),
1473 (ColorMapSlot::Accent4, ThemeColorSlot::Accent4),
1474 (ColorMapSlot::Accent5, ThemeColorSlot::Accent5),
1475 (ColorMapSlot::Accent6, ThemeColorSlot::Accent6),
1476 (ColorMapSlot::Hyperlink, ThemeColorSlot::Hyperlink),
1477 (
1478 ColorMapSlot::FollowedHyperlink,
1479 ThemeColorSlot::FollowedHyperlink,
1480 ),
1481 ];
1482
1483 for (source, destination) in expected {
1484 assert_eq!(map.theme_slot(source), destination);
1485 }
1486 }
1487
1488 #[test]
1489 fn dark_master_colour_map_inverts_background_and_text() {
1490 let map = ColorMap::default().with_overrides(&[
1491 (ColorMapSlot::Background1, ThemeColorSlot::Dark1),
1492 (ColorMapSlot::Text1, ThemeColorSlot::Light1),
1493 ]);
1494 let theme = [
1495 ("dk1", RgbColor::new(0x1F, 0x49, 0x7D)),
1496 ("lt1", RgbColor::new(0xEE, 0xDD, 0xCC)),
1497 ];
1498
1499 assert_eq!(
1500 resolve_color(
1501 &parse(br#"<a:schemeClr val="bg1"><a:tint val="62000"/></a:schemeClr>"#),
1502 &map,
1503 &theme,
1504 )
1505 .unwrap(),
1506 ResolvedColor::new(167, 174, 189, 255)
1507 );
1508 assert_eq!(
1509 resolve_color(&parse(br#"<a:schemeClr val="tx1"/>"#), &map, &theme).unwrap(),
1510 ResolvedColor::new(0xEE, 0xDD, 0xCC, 255)
1511 );
1512 }
1513
1514 #[test]
1515 fn colour_map_override_wins_before_theme_lookup() {
1516 let master = ColorMap::new(
1517 ThemeColorSlot::Dark2,
1518 ThemeColorSlot::Light2,
1519 ThemeColorSlot::Accent3,
1520 ThemeColorSlot::Accent4,
1521 ThemeColorSlot::Accent5,
1522 ThemeColorSlot::Accent6,
1523 ThemeColorSlot::Accent1,
1524 ThemeColorSlot::Accent2,
1525 ThemeColorSlot::Dark1,
1526 ThemeColorSlot::Light1,
1527 ThemeColorSlot::FollowedHyperlink,
1528 ThemeColorSlot::Hyperlink,
1529 );
1530 let map = master.with_overrides(&[(ColorMapSlot::Background1, ThemeColorSlot::Accent6)]);
1531 let expected = ColorMap::new(
1532 ThemeColorSlot::Accent6,
1533 ThemeColorSlot::Light2,
1534 ThemeColorSlot::Accent3,
1535 ThemeColorSlot::Accent4,
1536 ThemeColorSlot::Accent5,
1537 ThemeColorSlot::Accent6,
1538 ThemeColorSlot::Accent1,
1539 ThemeColorSlot::Accent2,
1540 ThemeColorSlot::Dark1,
1541 ThemeColorSlot::Light1,
1542 ThemeColorSlot::FollowedHyperlink,
1543 ThemeColorSlot::Hyperlink,
1544 );
1545
1546 assert_eq!(map, expected);
1547 assert_eq!(
1548 master.theme_slot(ColorMapSlot::Background1),
1549 ThemeColorSlot::Dark2
1550 );
1551 }
1552
1553 #[test]
1554 fn direct_colours_bypass_the_master_colour_map() {
1555 let standard = ColorMap::default();
1556 let dark = standard.with_overrides(&[
1557 (ColorMapSlot::Background1, ThemeColorSlot::Dark1),
1558 (ColorMapSlot::Text1, ThemeColorSlot::Light1),
1559 ]);
1560 let lookup = [
1561 ("windowText", RgbColor::new(0x10, 0x20, 0x30)),
1562 ("aliceBlue", RgbColor::new(0xF0, 0xF8, 0xFF)),
1563 ];
1564 let direct = [
1565 (
1566 parse(br#"<a:srgbClr val="EEECE1"><a:shade val="58000"/></a:srgbClr>"#),
1567 ResolvedColor::new(187, 185, 176, 255),
1568 ),
1569 (
1570 parse(br#"<a:sysClr val="windowText" lastClr="FFFFFF"/>"#),
1571 ResolvedColor::new(0x10, 0x20, 0x30, 255),
1572 ),
1573 (
1574 parse(br#"<a:sysClr val="missing" lastClr="AABBCC"/>"#),
1575 ResolvedColor::new(0xAA, 0xBB, 0xCC, 255),
1576 ),
1577 (
1578 parse(br#"<a:prstClr val="aliceBlue"/>"#),
1579 ResolvedColor::new(0xF0, 0xF8, 0xFF, 255),
1580 ),
1581 ];
1582
1583 for (colour, expected) in direct {
1584 assert_eq!(
1585 resolve_color(&colour, &standard, &lookup).unwrap(),
1586 expected
1587 );
1588 assert_eq!(resolve_color(&colour, &dark, &lookup).unwrap(), expected);
1589 }
1590 }
1591
1592 #[test]
1593 fn powerpoint_colour_transform_oracle_matches_all_forty_pairs() {
1594 assert_eq!(POWERPOINT_ORACLE_VERSION, "16.104");
1595 assert_eq!(POWERPOINT_ORACLE_BUILD, "16.104.25121423");
1596 assert_eq!(ORACLE_CASES.len(), 40);
1597
1598 for case in ORACLE_CASES {
1599 assert_eq!(
1600 apply_color_transforms(case.input, case.transforms).rgba(),
1601 case.expected,
1602 "PowerPoint colour oracle disagreement for {}",
1603 case.name
1604 );
1605 }
1606 }
1607
1608 #[test]
1609 fn colour_transforms_apply_in_document_order() {
1610 let base = RgbColor::new(0x33, 0x66, 0x99);
1611 let forward = apply_color_transforms(
1612 base,
1613 &[
1614 ColorTransform::RedOffset(Percent1000(40_000)),
1615 ColorTransform::RedModulation(Percent1000(50_000)),
1616 ],
1617 );
1618 let reversed = apply_color_transforms(
1619 base,
1620 &[
1621 ColorTransform::RedModulation(Percent1000(50_000)),
1622 ColorTransform::RedOffset(Percent1000(40_000)),
1623 ],
1624 );
1625
1626 assert_eq!(forward.red, 128);
1627 assert_eq!(reversed.red, 173);
1628 assert_ne!(forward, reversed);
1629 }
1630
1631 #[test]
1632 fn linear_gamma_round_trip_preserves_channel_endpoints() {
1633 assert_eq!(srgb_to_linear(0.0), 0.0);
1634 assert_eq!(srgb_to_linear(1.0), 1.0);
1635 assert_eq!(linear_to_srgb(0.0), 0.0);
1636 assert!((linear_to_srgb(1.0) - 1.0).abs() < f64::EPSILON);
1637 for channel in [0.01, 0.18, 0.5, 0.75] {
1638 assert!((linear_to_srgb(srgb_to_linear(channel)) - channel).abs() < 1e-12);
1639 }
1640 }
1641
1642 #[test]
1643 fn alpha_transforms_clamp_to_the_valid_range() {
1644 let colour = apply_color_transforms(
1645 RgbColor::new(0x12, 0x34, 0x56),
1646 &[
1647 ColorTransform::Alpha(Percent1000(75_000)),
1648 ColorTransform::AlphaModulation(Percent1000(50_000)),
1649 ColorTransform::AlphaOffset(Percent1000(80_000)),
1650 ],
1651 );
1652
1653 assert_eq!(colour, ResolvedColor::new(0x12, 0x34, 0x56, 255));
1654 }
1655
1656 #[test]
1657 fn known_and_unknown_transform_children_keep_document_order() {
1658 let input = br#"<x:srgbClr val="336699"><z:before z:id="1"/><x:tint val="65000"/><z:middle><z:leaf/></z:middle><x:hueOff val="5400000"/><z:after z:id="3"/></x:srgbClr>"#;
1659 let colour = parse(input);
1660
1661 assert_eq!(
1662 colour.transforms(),
1663 &[
1664 ColorTransform::Tint(Percent1000(65_000)),
1665 ColorTransform::HueOffset(Angle(5_400_000)),
1666 ]
1667 );
1668 assert_eq!(
1669 write(&colour),
1670 br#"<a:srgbClr val="336699"><z:before z:id="1"/><a:tint val="65000"/><z:middle><z:leaf/></z:middle><a:hueOff val="5400000"/><z:after z:id="3"/></a:srgbClr>"#
1671 );
1672 }
1673
1674 #[test]
1675 fn nonempty_known_transform_preserves_its_nested_xml_verbatim() {
1676 let input = br#"<x:srgbClr val="336699"><x:tint val="65000"><z:extension z:id="1"/></x:tint></x:srgbClr>"#;
1677 let colour = parse(input);
1678
1679 assert!(colour.transforms().is_empty());
1680 assert_eq!(
1681 write(&colour),
1682 br#"<a:srgbClr val="336699"><x:tint val="65000"><z:extension z:id="1"/></x:tint></a:srgbClr>"#
1683 );
1684 }
1685
1686 #[test]
1687 fn explicit_empty_transform_pair_is_modelled_and_canonicalised() {
1688 let input = br#"<x:srgbClr val="336699"><x:tint val="65000"></x:tint></x:srgbClr>"#;
1689 let colour = parse(input);
1690
1691 assert_eq!(
1692 colour.transforms(),
1693 &[ColorTransform::Tint(Percent1000(65_000))]
1694 );
1695 assert_eq!(
1696 write(&colour),
1697 br#"<a:srgbClr val="336699"><a:tint val="65000"/></a:srgbClr>"#
1698 );
1699 }
1700
1701 #[test]
1702 fn partially_transparent_rgba_matches_powerpoint_png_quantization() {
1703 let offset = apply_color_transforms(
1704 RgbColor::new(0x4B, 0xAC, 0xC6),
1705 &[ColorTransform::AlphaOffset(Percent1000(-30_000))],
1706 );
1707 let modulation = apply_color_transforms(
1708 RgbColor::new(0xF7, 0x96, 0x46),
1709 &[ColorTransform::AlphaModulation(Percent1000(43_000))],
1710 );
1711
1712 assert_eq!(offset.rgba(), [76, 172, 198, 179]);
1713 assert_eq!(modulation.rgba(), [248, 151, 70, 110]);
1714 }
1715
1716 #[test]
1717 #[ignore = "requires RDOCX_POWERPOINT_ORACLE_SHELL, pinned Microsoft PowerPoint, and native shape clipboard PNGs"]
1718 fn generate_powerpoint_colour_transform_oracle() {
1719 assert_powerpoint_build();
1720 let supplied_shell = PathBuf::from(
1721 std::env::var_os("RDOCX_POWERPOINT_ORACLE_SHELL").expect(
1722 "RDOCX_POWERPOINT_ORACLE_SHELL must name a PowerPoint-authored PPTX with one blank slide and one shape named probe_shape",
1723 ),
1724 );
1725 let output_dir = std::env::temp_dir().join(format!(
1726 "rdocx-f055-powerpoint-oracle-{}",
1727 std::process::id()
1728 ));
1729 fs::create_dir_all(&output_dir).unwrap();
1730 let shell_path = output_dir.join("powerpoint-native-shell.pptx");
1731 let deck_path = output_dir.join("colour-transform-oracle.pptx");
1732 fs::write(&shell_path, fs::read(&supplied_shell).unwrap()).unwrap();
1733 validate_powerpoint_shell(&shell_path);
1734 inject_oracle_transforms(&shell_path, &deck_path);
1735 validate_powerpoint_deck(&deck_path);
1736 export_oracle_shapes(&deck_path, &output_dir);
1737
1738 for case in ORACLE_CASES {
1739 let rgba = sample_uniform_centre(&output_dir.join(format!("{}.png", case.name)));
1740 let implementation = apply_color_transforms(case.input, case.transforms).rgba();
1741 println!(
1742 "{}: PowerPoint {rgba:?}, implementation {implementation:?}",
1743 case.name
1744 );
1745 }
1746 println!("oracle artefacts: {}", output_dir.display());
1747 }
1748
1749 fn assert_powerpoint_build() {
1750 let app = "/Applications/Microsoft PowerPoint.app/Contents/Info.plist";
1751 let version = Command::new("/usr/libexec/PlistBuddy")
1752 .args(["-c", "Print :CFBundleShortVersionString", app])
1753 .output()
1754 .unwrap();
1755 let build = Command::new("/usr/libexec/PlistBuddy")
1756 .args(["-c", "Print :CFBundleVersion", app])
1757 .output()
1758 .unwrap();
1759 assert!(version.status.success());
1760 assert!(build.status.success());
1761 assert_eq!(
1762 String::from_utf8(version.stdout).unwrap().trim(),
1763 POWERPOINT_ORACLE_VERSION
1764 );
1765 assert_eq!(
1766 String::from_utf8(build.stdout).unwrap().trim(),
1767 POWERPOINT_ORACLE_BUILD
1768 );
1769 }
1770
1771 fn validate_powerpoint_shell(path: &Path) {
1772 let path = path.to_string_lossy();
1773 let name = Path::new(path.as_ref())
1774 .file_name()
1775 .unwrap()
1776 .to_string_lossy();
1777 let mut script = powerpoint_script_start(120);
1778 script.push_str(&format!(
1779 "set deckPath to \"{path}\"\nopen my POSIX file deckPath\nset shellDeck to presentation \"{name}\"\nif (full name of shellDeck) is not deckPath then error \"oracle shell exact path mismatch\"\nif (count of slides of shellDeck) is not 1 then error \"oracle shell slide count mismatch\"\nif (count of shapes of slide 1 of shellDeck) is not 1 then error \"oracle shell shape count mismatch\"\nif (name of shape 1 of slide 1 of shellDeck) is not \"probe_shape\" then error \"oracle shell lacks probe_shape\"\nclose shellDeck saving no\n"
1780 ));
1781 script.push_str(&powerpoint_script_finish("shellDeck"));
1782 run_powerpoint_script(&script, "PowerPoint shell validation");
1783 }
1784
1785 fn validate_powerpoint_deck(path: &Path) {
1786 let path = path.to_string_lossy();
1787 let name = Path::new(path.as_ref())
1788 .file_name()
1789 .unwrap()
1790 .to_string_lossy();
1791 let mut script = powerpoint_script_start(120);
1792 script.push_str(&format!(
1793 "set deckPath to \"{path}\"\nopen my POSIX file deckPath\nset checkedDeck to presentation \"{name}\"\nif (full name of checkedDeck) is not deckPath then error \"oracle deck exact path mismatch\"\nif (count of slides of checkedDeck) is not 1 then error \"oracle deck slide count mismatch\"\nif (count of shapes of slide 1 of checkedDeck) is not 40 then error \"oracle deck shape count mismatch\"\nset oracleShapeNames to name of every shape of slide 1 of checkedDeck\n"
1794 ));
1795 for case in ORACLE_CASES {
1796 script.push_str(&format!(
1797 "if oracleShapeNames does not contain \"{}\" then error \"missing oracle shape {}\"\n",
1798 case.name, case.name
1799 ));
1800 }
1801 script.push_str("close checkedDeck saving no\n");
1802 script.push_str(&powerpoint_script_finish("checkedDeck"));
1803 run_powerpoint_script(&script, "PowerPoint deck validation");
1804 }
1805
1806 fn inject_oracle_transforms(shell_path: &Path, deck_path: &Path) {
1807 let mut package = OpcPackage::open(shell_path).unwrap();
1808 let presentation_part = package.main_document_part().unwrap();
1809 let slide_target = package
1810 .get_part_rels(&presentation_part)
1811 .unwrap()
1812 .get_by_type(rel_types::SLIDE)
1813 .unwrap()
1814 .target
1815 .clone();
1816 let slide_part = OpcPackage::resolve_rel_target(&presentation_part, &slide_target);
1817 let mut slide_xml =
1818 String::from_utf8(package.get_part(&slide_part).unwrap().to_vec()).unwrap();
1819
1820 let name_marker = "name=\"probe_shape\"";
1821 let name_index = slide_xml.find(name_marker).unwrap();
1822 let shape_start = slide_xml[..name_index].rfind("<p:sp>").unwrap();
1823 let shape_end =
1824 name_index + slide_xml[name_index..].find("</p:sp>").unwrap() + "</p:sp>".len();
1825 let shape_template = &slide_xml[shape_start..shape_end];
1826 let mut generated_shapes = String::new();
1827
1828 for (index, case) in ORACLE_CASES.iter().enumerate() {
1829 let mut shape = shape_template.replacen(
1830 "id=\"2\" name=\"probe_shape\"",
1831 &format!("id=\"{}\" name=\"{}\"", index + 2, case.name),
1832 1,
1833 );
1834 let mut transform_writer = Writer::new(Vec::new());
1835 for transform in case.transforms {
1836 transform.to_xml(&mut transform_writer).unwrap();
1837 }
1838 let transform_xml = String::from_utf8(transform_writer.into_inner()).unwrap();
1839 let replacement = format!(
1840 "<a:srgbClr val=\"{}\">{transform_xml}</a:srgbClr>",
1841 case.input
1842 );
1843 shape = shape.replacen("<a:srgbClr val=\"1F497D\"/>", &replacement, 1);
1844 assert!(shape.contains(&format!("name=\"{}\"", case.name)));
1845 assert!(shape.contains(&replacement));
1846 generated_shapes.push_str(&shape);
1847 }
1848 slide_xml.replace_range(shape_start..shape_end, &generated_shapes);
1849
1850 package.set_part(&slide_part, slide_xml.into_bytes());
1851 package.save(deck_path).unwrap();
1852 }
1853
1854 fn powerpoint_script_start(timeout_seconds: u32) -> String {
1855 format!(
1856 "with timeout of {timeout_seconds} seconds\ntell application \"Microsoft PowerPoint\"\nset previousStartUpDialog to start up dialog\ntry\nif (Version as text) is not \"{POWERPOINT_ORACLE_VERSION}\" then error \"PowerPoint version mismatch: \" & (Version as text)\nif (build as text) is not \"{POWERPOINT_ORACLE_APP_BUILD}\" then error \"PowerPoint application build mismatch: \" & (build as text)\nset start up dialog to false\n"
1857 )
1858 }
1859
1860 fn powerpoint_script_finish(deck_variable: &str) -> String {
1861 format!(
1862 "set start up dialog to previousStartUpDialog\non error errorMessage number errorNumber\ntry\nclose {deck_variable} saving no\nend try\nset start up dialog to previousStartUpDialog\nerror errorMessage number errorNumber\nend try\nend tell\nend timeout\n"
1863 )
1864 }
1865
1866 fn run_powerpoint_script(script: &str, action: &str) {
1867 let result = Command::new("osascript")
1868 .args(["-e", script])
1869 .output()
1870 .unwrap();
1871 assert!(
1872 result.status.success(),
1873 "{action} failed: {}",
1874 String::from_utf8_lossy(&result.stderr)
1875 );
1876 }
1877
1878 fn export_oracle_shapes(deck_path: &Path, output_dir: &Path) {
1879 let deck = deck_path.to_string_lossy();
1880 let name = Path::new(deck.as_ref())
1881 .file_name()
1882 .unwrap()
1883 .to_string_lossy();
1884 let mut script = powerpoint_script_start(600);
1885 script.push_str(&format!(
1886 "set deckPath to \"{deck}\"\nopen my POSIX file deckPath\nset oracleDeck to presentation \"{name}\"\nif (full name of oracleDeck) is not deckPath then error \"oracle export exact path mismatch\"\n"
1887 ));
1888 for case in ORACLE_CASES {
1889 let output = output_dir.join(format!("{}.png", case.name));
1890 script.push_str(&format!(
1891 "set oracleShape to shape \"{}\" of slide 1 of oracleDeck\ncopy shape oracleShape\ndelay 0.5\ntell me\nset pngData to the clipboard as «class PNGf»\nset outputFile to open for access (POSIX file \"{}\") with write permission\nset eof outputFile to 0\nwrite pngData to outputFile\nclose access outputFile\nend tell\n",
1892 case.name,
1893 output.to_string_lossy()
1894 ));
1895 }
1896 script.push_str("close oracleDeck saving no\n");
1897 script.push_str(&powerpoint_script_finish("oracleDeck"));
1898 run_powerpoint_script(&script, "PowerPoint direct shape clipboard render");
1899 }
1900
1901 fn sample_uniform_centre(path: &Path) -> [u8; 4] {
1902 let rgb = run_pngtopnm(path, false);
1903 let alpha = run_pngtopnm(path, true);
1904 assert_eq!((rgb.width, rgb.height), (alpha.width, alpha.height));
1905 let centre_x = rgb.width / 2;
1906 let centre_y = rgb.height / 2;
1907 let mut sample = None;
1908 for y in centre_y - 2..=centre_y + 2 {
1909 for x in centre_x - 2..=centre_x + 2 {
1910 let rgb_offset = (y * rgb.width + x) * 3;
1911 let alpha_offset = y * alpha.width + x;
1912 let pixel = [
1913 rgb.data[rgb_offset],
1914 rgb.data[rgb_offset + 1],
1915 rgb.data[rgb_offset + 2],
1916 alpha.data[alpha_offset],
1917 ];
1918 assert_eq!(
1919 *sample.get_or_insert(pixel),
1920 pixel,
1921 "non-uniform 5 by 5 centre block in {}",
1922 path.display()
1923 );
1924 }
1925 }
1926 sample.unwrap()
1927 }
1928
1929 struct NetpbmImage {
1930 width: usize,
1931 height: usize,
1932 data: Vec<u8>,
1933 }
1934
1935 fn run_pngtopnm(path: &Path, alpha: bool) -> NetpbmImage {
1936 let mut command = Command::new("pngtopnm");
1937 if alpha {
1938 command.arg("-alpha");
1939 }
1940 let output = command.arg(path).output().unwrap();
1941 assert!(
1942 output.status.success(),
1943 "pngtopnm failed for {}: {}",
1944 path.display(),
1945 String::from_utf8_lossy(&output.stderr)
1946 );
1947 parse_netpbm(output.stdout, if alpha { b'5' } else { b'6' })
1948 }
1949
1950 fn parse_netpbm(bytes: Vec<u8>, expected_kind: u8) -> NetpbmImage {
1951 let mut cursor = 0;
1952 let magic = netpbm_token(&bytes, &mut cursor);
1953 assert_eq!(magic, [b'P', expected_kind]);
1954 let width = parse_netpbm_usize(netpbm_token(&bytes, &mut cursor));
1955 let height = parse_netpbm_usize(netpbm_token(&bytes, &mut cursor));
1956 assert_eq!(parse_netpbm_usize(netpbm_token(&bytes, &mut cursor)), 255);
1957 assert!(bytes[cursor].is_ascii_whitespace());
1958 cursor += 1;
1959 let channels = if expected_kind == b'6' { 3 } else { 1 };
1960 assert_eq!(bytes.len() - cursor, width * height * channels);
1961 NetpbmImage {
1962 width,
1963 height,
1964 data: bytes[cursor..].to_vec(),
1965 }
1966 }
1967
1968 fn netpbm_token<'a>(bytes: &'a [u8], cursor: &mut usize) -> &'a [u8] {
1969 loop {
1970 while bytes[*cursor].is_ascii_whitespace() {
1971 *cursor += 1;
1972 }
1973 if bytes[*cursor] != b'#' {
1974 break;
1975 }
1976 while bytes[*cursor] != b'\n' {
1977 *cursor += 1;
1978 }
1979 }
1980 let start = *cursor;
1981 while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
1982 *cursor += 1;
1983 }
1984 &bytes[start..*cursor]
1985 }
1986
1987 fn parse_netpbm_usize(token: &[u8]) -> usize {
1988 std::str::from_utf8(token).unwrap().parse().unwrap()
1989 }
1990
1991 #[test]
1992 fn all_twenty_eight_transform_elements_parse_and_write_with_fixed_prefixes() {
1993 let input = br#"<x:srgbClr val="123456"><x:tint val="10000"/><x:shade val="20000"/><x:comp/><x:inv/><x:gray/><x:alpha val="30000"/><x:alphaOff val="-40000"/><x:alphaMod val="50000"/><x:hue val="60000"/><x:hueOff val="-120000"/><x:hueMod val="60000"/><x:sat val="70000"/><x:satOff val="-80000"/><x:satMod val="90000"/><x:lum val="100000"/><x:lumOff val="-10000"/><x:lumMod val="100000"/><x:red val="12000"/><x:redOff val="-13000"/><x:redMod val="40000"/><x:green val="15000"/><x:greenOff val="-16000"/><x:greenMod val="70000"/><x:blue val="18000"/><x:blueOff val="-19000"/><x:blueMod val="100000"/><x:gamma/><x:invGamma/></x:srgbClr>"#;
1994 let colour = parse(input);
1995
1996 assert_eq!(colour.transforms().len(), 28);
1997 let output = String::from_utf8(write(&colour)).unwrap();
1998 assert!(!output.contains("<x:"));
1999 assert!(output.contains("<a:hue val=\"60000\"/>"));
2000 assert!(output.contains("<a:hueOff val=\"-120000\"/>"));
2001 assert!(output.contains("<a:invGamma/>"));
2002 }
2003
2004 #[test]
2005 fn whitespace_and_comments_in_empty_transform_pairs_are_modelled() {
2006 let input = br#"<x:srgbClr val="336699"><x:tint val="65000">
2007 <!-- formatting only -->
2008 </x:tint></x:srgbClr>"#;
2009 let colour = parse(input);
2010
2011 assert_eq!(
2012 colour.transforms(),
2013 &[ColorTransform::Tint(Percent1000(65_000))]
2014 );
2015 assert_eq!(
2016 write(&colour),
2017 br#"<a:srgbClr val="336699"><a:tint val="65000"/></a:srgbClr>"#
2018 );
2019 }
2020}