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