diff --git a/Cargo.lock b/Cargo.lock index 861370f19..a01fb6398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arrayref" version = "0.3.7" @@ -705,6 +714,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "1.9.0" @@ -1535,15 +1550,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e25be21376a772d15f97ae789845340a9651d3c4246ff5ebb6a2b35f9c37bd31" -[[package]] -name = "oklab" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "467e40ada50d13bab19019e3707862b5076ca15841f31ee1474c40397c1b9f11" -dependencies = [ - "rgb", -] - [[package]] name = "once_cell" version = "1.18.0" @@ -1591,6 +1597,29 @@ dependencies = [ "zopfli", ] +[[package]] +name = "palette" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e2f34147767aa758aa649415b50a69eeb46a67f9dc7db8011eeb3d84b351dc" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7db010ec5ff3d4385e4f133916faacd9dad0f6a09394c92d825b3aed310fa0a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "parking_lot_core" version = "0.9.8" @@ -2715,8 +2744,8 @@ dependencies = [ "indexmap 2.0.0", "log", "miniz_oxide", - "oklab", "once_cell", + "palette", "pdf-writer", "pixglyph", "regex", diff --git a/NOTICE b/NOTICE index cbe9da9b2..18ed0c621 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,126 @@ Licenses for third party components used by this project can be found below. +================================================================================ +The Creative Commons Zero v1.0 Universal License applies to: +* The ICC profiles found in `crates/typst/icc/*` + +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see +http://creativecommons.org/publicdomain/zero/1.0/ + ================================================================================ The 0BSD License applies to: diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index bdb97f844..03e964412 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -63,6 +63,7 @@ fn prelude(global: &mut Scope) { global.define("green", Color::GREEN); global.define("lime", Color::LIME); global.define("luma", Color::luma_data()); + global.define("oklab", Color::oklab_data()); global.define("rgb", Color::rgb_data()); global.define("cmyk", Color::cmyk_data()); global.define("range", Array::range_data()); diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index 4f85abef3..6fd56bb63 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -240,7 +240,7 @@ pub struct HighlightElem { /// ```example /// This is #highlight(fill: blue)[with blue]. /// ``` - #[default(Color::Rgba(RgbaColor::new(0xFF, 0xFF, 0x5F, 0xFF)).into())] + #[default(Color::from_u8(0xFF, 0xFF, 0x5F, 0xFF).into())] pub fill: Paint, /// The top end of the background rectangle. diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 1f46f94d7..688e69d8f 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -454,11 +454,12 @@ fn styled(piece: &str, foreground: Paint, style: synt::Style) -> Content { body } -fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> RgbaColor { - RgbaColor { r, g, b, a } +fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> Color { + Color::from_u8(r, g, b, a) } -fn to_syn(RgbaColor { r, g, b, a }: RgbaColor) -> synt::Color { +fn to_syn(color: Color) -> synt::Color { + let [r, g, b, a] = color.to_vec4_u8(); synt::Color { r, g, b, a } } @@ -628,7 +629,7 @@ fn item( synt::ThemeItem { scope: scope.parse().unwrap(), style: synt::StyleModifier { - foreground: color.map(|s| to_syn(s.parse::().unwrap())), + foreground: color.map(|s| to_syn(s.parse::().unwrap())), background: None, font_style, }, diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index e34281e3e..0775f67b6 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -30,10 +30,10 @@ image = { version = "0.24", default-features = false, features = ["png", "jpeg", indexmap = { version = "2", features = ["serde"] } log = "0.4" miniz_oxide = "0.7" -oklab = "1" once_cell = "1" pdf-writer = "0.8.1" pixglyph = "0.2" +palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } regex = "1" resvg = { version = "0.35.0", default-features = false, features = ["raster-images"] } roxmltree = "0.18" diff --git a/crates/typst/src/doc.rs b/crates/typst/src/doc.rs index 1068fc20d..8f7e080a1 100644 --- a/crates/typst/src/doc.rs +++ b/crates/typst/src/doc.rs @@ -13,8 +13,7 @@ use crate::export::PdfPageLabel; use crate::font::Font; use crate::geom::{ self, rounded_rect, Abs, Axes, Color, Corners, Dir, Em, FixedAlign, FixedStroke, - Geometry, Length, Numeric, Paint, Point, Rel, RgbaColor, Shape, Sides, Size, - Transform, + Geometry, Length, Numeric, Paint, Point, Rel, Shape, Sides, Size, Transform, }; use crate::image::Image; use crate::model::{Content, Location, MetaElem, StyleChain}; @@ -352,8 +351,7 @@ impl Frame { 0, Point::zero(), FrameItem::Shape( - Geometry::Rect(self.size) - .filled(RgbaColor { a: 100, ..Color::TEAL.to_rgba() }.into()), + Geometry::Rect(self.size).filled(Color::TEAL.with_alpha(0.5).into()), Span::detached(), ), ); diff --git a/crates/typst/src/eval/func.rs b/crates/typst/src/eval/func.rs index 872373525..d8a2c3e8a 100644 --- a/crates/typst/src/eval/func.rs +++ b/crates/typst/src/eval/func.rs @@ -378,6 +378,15 @@ impl PartialEq for Func { } } +impl PartialEq<&NativeFuncData> for Func { + fn eq(&self, other: &&NativeFuncData) -> bool { + match &self.repr { + Repr::Native(native) => native.function == other.function, + _ => false, + } + } +} + impl From for Func { fn from(repr: Repr) -> Self { Self { repr, span: Span::detached() } diff --git a/crates/typst/src/eval/value.rs b/crates/typst/src/eval/value.rs index 539cb516d..776608b3a 100644 --- a/crates/typst/src/eval/value.rs +++ b/crates/typst/src/eval/value.rs @@ -604,7 +604,6 @@ primitive! { Plugin: "plugin", Plugin } mod tests { use super::*; use crate::eval::{array, dict}; - use crate::geom::RgbaColor; #[track_caller] fn test(value: impl IntoValue, exp: &str) { @@ -623,7 +622,6 @@ mod tests { test(Ratio::one() / 2.0, "50%"); test(Ratio::new(0.3) + Length::from(Abs::cm(2.0)), "30% + 56.69pt"); test(Fr::one() * 7.55, "7.55fr"); - test(Color::Rgba(RgbaColor::new(1, 1, 1, 0xff)), "rgb(\"#010101\")"); // Collections. test("hello", r#""hello""#); diff --git a/crates/typst/src/export/pdf/color.rs b/crates/typst/src/export/pdf/color.rs new file mode 100644 index 000000000..1ca0c3f79 --- /dev/null +++ b/crates/typst/src/export/pdf/color.rs @@ -0,0 +1,428 @@ +use std::sync::Arc; + +use pdf_writer::types::DeviceNSubtype; +use pdf_writer::{writers, Dict, Filter, Name, PdfWriter, Ref}; + +use super::page::PageContext; +use super::RefExt; +use crate::export::pdf::deflate; +use crate::geom::{Color, ColorSpace, Paint}; + +// The names of the color spaces. +pub const SRGB: Name<'static> = Name(b"srgb"); +pub const D65_GRAY: Name<'static> = Name(b"d65gray"); +pub const OKLAB: Name<'static> = Name(b"oklab"); +pub const HSV: Name<'static> = Name(b"hsv"); +pub const HSL: Name<'static> = Name(b"hsl"); +pub const LINEAR_SRGB: Name<'static> = Name(b"linearrgb"); + +// The names of the color components. +const OKLAB_L: Name<'static> = Name(b"L"); +const OKLAB_A: Name<'static> = Name(b"A"); +const OKLAB_B: Name<'static> = Name(b"B"); +const HSV_H: Name<'static> = Name(b"H"); +const HSV_S: Name<'static> = Name(b"S"); +const HSV_V: Name<'static> = Name(b"V"); +const HSL_H: Name<'static> = Name(b"H"); +const HSL_S: Name<'static> = Name(b"S"); +const HSL_L: Name<'static> = Name(b"L"); + +// The ICC profiles. +const SRGB_ICC: &[u8] = include_bytes!("./icc/sRGB-v4.icc"); +const GRAY_ICC: &[u8] = include_bytes!("./icc/sGrey-v4.icc"); + +// The PostScript functions for color spaces. +const OKLAB_SOURCE: &str = include_str!("./postscript/oklab.ps"); +const HSL_SOURCE: &str = include_str!("./postscript/hsl.ps"); +const HSV_SOURCE: &str = include_str!("./postscript/hsv.ps"); + +/// The color spaces present in the PDF document +#[derive(Default)] +pub struct ColorSpaces { + oklab: Option, + srgb: Option, + d65_gray: Option, + hsv: Option, + hsl: Option, + use_linear_rgb: bool, +} + +impl ColorSpaces { + /// Get a reference to the oklab color space. + /// + /// # Warning + /// The A and B components of the color must be offset by +0.4 before being + /// encoded into the PDF file. + pub fn oklab(&mut self, alloc: &mut Ref) -> Ref { + *self.oklab.get_or_insert_with(|| alloc.bump()) + } + + /// Get a reference to the srgb color space. + pub fn srgb(&mut self, alloc: &mut Ref) -> Ref { + *self.srgb.get_or_insert_with(|| alloc.bump()) + } + + /// Get a reference to the gray color space. + pub fn d65_gray(&mut self, alloc: &mut Ref) -> Ref { + *self.d65_gray.get_or_insert_with(|| alloc.bump()) + } + + /// Get a reference to the hsv color space. + /// + /// # Warning + /// The Hue component of the color must be in degrees and must be divided + /// by 360.0 before being encoded into the PDF file. + pub fn hsv(&mut self, alloc: &mut Ref) -> Ref { + *self.hsv.get_or_insert_with(|| alloc.bump()) + } + + /// Get a reference to the hsl color space. + /// + /// # Warning + /// The Hue component of the color must be in degrees and must be divided + /// by 360.0 before being encoded into the PDF file. + pub fn hsl(&mut self, alloc: &mut Ref) -> Ref { + *self.hsl.get_or_insert_with(|| alloc.bump()) + } + + /// Mark linear RGB as used. + pub fn linear_rgb(&mut self) { + self.use_linear_rgb = true; + } + + /// Write the color space on usage. + pub fn write( + &mut self, + color_space: ColorSpace, + writer: writers::ColorSpace, + alloc: &mut Ref, + ) { + match color_space { + ColorSpace::Oklab => { + let mut oklab = writer.device_n([OKLAB_L, OKLAB_A, OKLAB_B]); + self.write(ColorSpace::LinearRgb, oklab.alternate_color_space(), alloc); + oklab.tint_ref(self.oklab(alloc)); + oklab.attrs().subtype(DeviceNSubtype::DeviceN); + } + ColorSpace::Srgb => writer.icc_based(self.srgb(alloc)), + ColorSpace::D65Gray => writer.icc_based(self.d65_gray(alloc)), + ColorSpace::LinearRgb => { + writer.cal_rgb( + [0.9505, 1.0, 1.0888], + None, + Some([1.0, 1.0, 1.0]), + Some([ + 0.4124, 0.2126, 0.0193, 0.3576, 0.715, 0.1192, 0.1805, 0.0722, + 0.9505, + ]), + ); + } + ColorSpace::Hsl => { + let mut hsl = writer.device_n([HSL_H, HSL_S, HSL_L]); + self.write(ColorSpace::Srgb, hsl.alternate_color_space(), alloc); + hsl.tint_ref(self.hsl(alloc)); + hsl.attrs().subtype(DeviceNSubtype::DeviceN); + } + ColorSpace::Hsv => { + let mut hsv = writer.device_n([HSV_H, HSV_S, HSV_V]); + self.write(ColorSpace::Srgb, hsv.alternate_color_space(), alloc); + hsv.tint_ref(self.hsv(alloc)); + hsv.attrs().subtype(DeviceNSubtype::DeviceN); + } + ColorSpace::Cmyk => writer.device_cmyk(), + } + } + + // Write the color spaces to the PDF file. + pub fn write_color_spaces(&mut self, mut spaces: Dict, alloc: &mut Ref) { + if self.oklab.is_some() { + self.write(ColorSpace::Oklab, spaces.insert(OKLAB).start(), alloc); + } + + if self.srgb.is_some() { + self.write(ColorSpace::Srgb, spaces.insert(SRGB).start(), alloc); + } + + if self.d65_gray.is_some() { + self.write(ColorSpace::D65Gray, spaces.insert(D65_GRAY).start(), alloc); + } + + if self.hsv.is_some() { + self.write(ColorSpace::Hsv, spaces.insert(HSV).start(), alloc); + } + + if self.hsl.is_some() { + self.write(ColorSpace::Hsl, spaces.insert(HSL).start(), alloc); + } + + if self.use_linear_rgb { + self.write(ColorSpace::LinearRgb, spaces.insert(LINEAR_SRGB).start(), alloc); + } + } + + /// Write the necessary color spaces functions and ICC profiles to the + /// PDF file. + pub fn write_functions(&self, writer: &mut PdfWriter) { + // Write the Oklab function & color space + if let Some(oklab) = self.oklab { + let code = oklab_function(); + writer + .post_script_function(oklab, &code) + .domain([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .filter(Filter::FlateDecode); + } + + // Write the HSV function & color space + if let Some(hsv) = self.hsv { + let code = hsv_function(); + writer + .post_script_function(hsv, &code) + .domain([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .filter(Filter::FlateDecode); + } + + // Write the HSL function & color space + if let Some(hsl) = self.hsl { + let code = hsl_function(); + writer + .post_script_function(hsl, &code) + .domain([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .filter(Filter::FlateDecode); + } + + // Write the sRGB color space + if let Some(srgb) = self.srgb { + let profile = srgb_icc(); + writer + .icc_profile(srgb, &profile) + .n(3) + .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + .alternate() + .srgb(); + } + + // Write the gray color space + if let Some(gray) = self.d65_gray { + let profile = gray_icc(); + writer + .icc_profile(gray, &profile) + .n(1) + .range([0.0, 1.0]) + .alternate() + .d65_gray(); + } + } +} + +/// Deflated sRGB ICC profile +#[comemo::memoize] +fn srgb_icc() -> Arc> { + Arc::new(deflate(SRGB_ICC)) +} + +/// Deflated gray ICC profile +#[comemo::memoize] +fn gray_icc() -> Arc> { + Arc::new(deflate(GRAY_ICC)) +} + +/// Deflated Oklab PostScript function +#[comemo::memoize] +fn oklab_function() -> Arc> { + let code = minify(OKLAB_SOURCE); + Arc::new(deflate(code.as_bytes())) +} + +/// Deflated HSV PostScript function +#[comemo::memoize] +fn hsv_function() -> Arc> { + let code = minify(HSV_SOURCE); + Arc::new(deflate(code.as_bytes())) +} + +/// Deflated HSL PostScript function +#[comemo::memoize] +fn hsl_function() -> Arc> { + let code = minify(HSL_SOURCE); + Arc::new(deflate(code.as_bytes())) +} + +/// This function removes comments, line spaces and carriage returns from a +/// PostScript program. This is necessary to optimize the size of the PDF file. +fn minify(source: &str) -> String { + let mut buf = String::with_capacity(source.len()); + let mut s = unscanny::Scanner::new(source); + while let Some(c) = s.eat() { + match c { + '%' => { + s.eat_until('\n'); + } + c if c.is_whitespace() => { + s.eat_whitespace(); + if buf.ends_with(|c: char| !c.is_whitespace()) { + buf.push(' '); + } + } + _ => buf.push(c), + } + } + buf +} + +/// Encodes the color into four f32s, which can be used in a PDF file. +/// Ensures that the values are in the range [0.0, 1.0]. +/// +/// # Why? +/// - Oklab: The a and b components are in the range [-0.4, 0.4] and the PDF +/// specifies (and some readers enforce) that all color values be in the range +/// [0.0, 1.0]. This means that the PostScript function and the encoded color +/// must be offset by 0.4. +/// - HSV/HSL: The hue component is in the range [0.0, 360.0] and the PDF format +/// specifies that it must be in the range [0.0, 1.0]. This means that the +/// PostScript function and the encoded color must be divided by 360.0. +pub trait ColorEncode { + /// Performs the color to PDF f32 array conversion. + fn encode(&self, color: Color) -> [f32; 4]; +} + +impl ColorEncode for ColorSpace { + fn encode(&self, color: Color) -> [f32; 4] { + match self { + ColorSpace::Oklab => { + let [l, a, b, alpha] = color.to_oklab().to_vec4(); + [l, (a + 0.4).clamp(0.0, 1.0), (b + 0.4).clamp(0.0, 1.0), alpha] + } + ColorSpace::Hsl => { + let [h, s, l, _] = color.to_hsl().to_vec4(); + [h / 360.0, s, l, 0.0] + } + ColorSpace::Hsv => { + let [h, s, v, _] = color.to_hsv().to_vec4(); + [h / 360.0, s, v, 0.0] + } + _ => color.to_vec4(), + } + } +} + +/// Encodes a paint into either a fill or stroke color. +pub trait PaintEncode { + /// Set the paint as the fill color. + fn set_as_fill(&self, page_context: &mut PageContext); + + /// Set the paint as the stroke color. + fn set_as_stroke(&self, page_context: &mut PageContext); +} + +impl PaintEncode for Paint { + fn set_as_fill(&self, ctx: &mut PageContext) { + let Paint::Solid(color) = self; + match color { + Color::Luma(_) => { + ctx.parent.colors.d65_gray(&mut ctx.parent.alloc); + ctx.set_fill_color_space(D65_GRAY); + + let [l, _, _, _] = ColorSpace::D65Gray.encode(*color); + ctx.content.set_fill_color([l]); + } + Color::Oklab(_) => { + ctx.parent.colors.oklab(&mut ctx.parent.alloc); + ctx.set_fill_color_space(OKLAB); + + let [l, a, b, _] = ColorSpace::Oklab.encode(*color); + ctx.content.set_fill_color([l, a, b]); + } + Color::LinearRgb(_) => { + ctx.parent.colors.linear_rgb(); + ctx.set_fill_color_space(LINEAR_SRGB); + + let [r, g, b, _] = ColorSpace::LinearRgb.encode(*color); + ctx.content.set_fill_color([r, g, b]); + } + Color::Rgba(_) => { + ctx.parent.colors.srgb(&mut ctx.parent.alloc); + ctx.set_fill_color_space(SRGB); + + let [r, g, b, _] = ColorSpace::Srgb.encode(*color); + ctx.content.set_fill_color([r, g, b]); + } + Color::Cmyk(_) => { + ctx.reset_fill_color_space(); + + let [c, m, y, k] = ColorSpace::Cmyk.encode(*color); + ctx.content.set_fill_cmyk(c, m, y, k); + } + Color::Hsl(_) => { + ctx.parent.colors.hsl(&mut ctx.parent.alloc); + ctx.set_fill_color_space(HSL); + + let [h, s, l, _] = ColorSpace::Hsl.encode(*color); + ctx.content.set_fill_color([h, s, l]); + } + Color::Hsv(_) => { + ctx.parent.colors.hsv(&mut ctx.parent.alloc); + ctx.set_fill_color_space(HSV); + + let [h, s, v, _] = ColorSpace::Hsv.encode(*color); + ctx.content.set_fill_color([h, s, v]); + } + } + } + + fn set_as_stroke(&self, ctx: &mut PageContext) { + let Paint::Solid(color) = self; + match color { + Color::Luma(_) => { + ctx.parent.colors.d65_gray(&mut ctx.parent.alloc); + ctx.set_stroke_color_space(D65_GRAY); + + let [l, _, _, _] = ColorSpace::D65Gray.encode(*color); + ctx.content.set_stroke_color([l]); + } + Color::Oklab(_) => { + ctx.parent.colors.oklab(&mut ctx.parent.alloc); + ctx.set_stroke_color_space(OKLAB); + + let [l, a, b, _] = ColorSpace::Oklab.encode(*color); + ctx.content.set_stroke_color([l, a, b]); + } + Color::LinearRgb(_) => { + ctx.parent.colors.linear_rgb(); + ctx.set_stroke_color_space(LINEAR_SRGB); + + let [r, g, b, _] = ColorSpace::LinearRgb.encode(*color); + ctx.content.set_stroke_color([r, g, b]); + } + Color::Rgba(_) => { + ctx.parent.colors.srgb(&mut ctx.parent.alloc); + ctx.set_stroke_color_space(SRGB); + + let [r, g, b, _] = ColorSpace::Srgb.encode(*color); + ctx.content.set_stroke_color([r, g, b]); + } + Color::Cmyk(_) => { + ctx.reset_stroke_color_space(); + + let [c, m, y, k] = ColorSpace::Cmyk.encode(*color); + ctx.content.set_stroke_cmyk(c, m, y, k); + } + Color::Hsl(_) => { + ctx.parent.colors.hsl(&mut ctx.parent.alloc); + ctx.set_stroke_color_space(HSL); + + let [h, s, l, _] = ColorSpace::Hsl.encode(*color); + ctx.content.set_stroke_color([h, s, l]); + } + Color::Hsv(_) => { + ctx.parent.colors.hsv(&mut ctx.parent.alloc); + ctx.set_stroke_color_space(HSV); + + let [h, s, v, _] = ColorSpace::Hsv.encode(*color); + ctx.content.set_stroke_color([h, s, v]); + } + } + } +} diff --git a/crates/typst/src/export/pdf/icc/sGrey-v4.icc b/crates/typst/src/export/pdf/icc/sGrey-v4.icc new file mode 100644 index 000000000..2187b6786 Binary files /dev/null and b/crates/typst/src/export/pdf/icc/sGrey-v4.icc differ diff --git a/crates/typst/src/export/pdf/icc/sRGB-v4.icc b/crates/typst/src/export/pdf/icc/sRGB-v4.icc new file mode 100644 index 000000000..d9f3c055b Binary files /dev/null and b/crates/typst/src/export/pdf/icc/sRGB-v4.icc differ diff --git a/crates/typst/src/export/pdf/mod.rs b/crates/typst/src/export/pdf/mod.rs index 7a831445e..6a5aacf55 100644 --- a/crates/typst/src/export/pdf/mod.rs +++ b/crates/typst/src/export/pdf/mod.rs @@ -1,11 +1,13 @@ //! Exporting into PDF documents. +mod color; mod extg; mod font; mod image; mod outline; mod page; +pub use self::color::{ColorEncode, ColorSpaces}; pub use self::page::{PdfPageLabel, PdfPageLabelStyle}; use std::cmp::Eq; @@ -43,15 +45,12 @@ pub fn pdf(document: &Document) -> Vec { ctx.writer.finish() } -/// Identifies the color space definitions. -const SRGB: Name<'static> = Name(b"srgb"); -const D65_GRAY: Name<'static> = Name(b"d65gray"); - /// Context for exporting a whole PDF document. pub struct PdfContext<'a> { document: &'a Document, introspector: Introspector, writer: PdfWriter, + colors: ColorSpaces, pages: Vec, page_heights: Vec, alloc: Ref, @@ -81,6 +80,7 @@ impl<'a> PdfContext<'a> { document, introspector: Introspector::new(&document.pages), writer: PdfWriter::new(), + colors: ColorSpaces::default(), pages: vec![], page_heights: vec![], alloc, diff --git a/crates/typst/src/export/pdf/page.rs b/crates/typst/src/export/pdf/page.rs index 12d05bcf1..d9798f54c 100644 --- a/crates/typst/src/export/pdf/page.rs +++ b/crates/typst/src/export/pdf/page.rs @@ -5,16 +5,16 @@ use pdf_writer::types::{ ActionType, AnnotationType, ColorSpaceOperand, LineCapStyle, LineJoinStyle, NumberingStyle, }; -use pdf_writer::writers::ColorSpace; use pdf_writer::{Content, Filter, Finish, Name, Rect, Ref, Str}; +use super::color::PaintEncode; use super::extg::ExternalGraphicsState; -use super::{deflate, AbsExt, EmExt, PdfContext, RefExt, D65_GRAY, SRGB}; +use super::{deflate, AbsExt, EmExt, PdfContext, RefExt}; use crate::doc::{Destination, Frame, FrameItem, GroupItem, Meta, TextItem}; use crate::font::Font; use crate::geom::{ - self, Abs, Color, Em, FixedStroke, Geometry, LineCap, LineJoin, Numeric, Paint, - Point, Ratio, Shape, Size, Transform, + self, Abs, Em, FixedStroke, Geometry, LineCap, LineJoin, Numeric, Paint, Point, + Ratio, Shape, Size, Transform, }; use crate::image::Image; @@ -86,10 +86,8 @@ pub fn write_page_tree(ctx: &mut PdfContext) { .kids(ctx.page_refs.iter().copied()); let mut resources = pages.resources(); - let mut spaces = resources.color_spaces(); - spaces.insert(SRGB).start::().srgb(); - spaces.insert(D65_GRAY).start::().d65_gray(); - spaces.finish(); + ctx.colors + .write_color_spaces(resources.color_spaces(), &mut ctx.alloc); let mut fonts = resources.fonts(); for (font_ref, f) in ctx.font_map.pdf_indices(&ctx.font_refs) { @@ -116,6 +114,9 @@ pub fn write_page_tree(ctx: &mut PdfContext) { resources.finish(); pages.finish(); + + // Write all of the functions used by the document. + ctx.colors.write_functions(&mut ctx.writer); } /// Write a page tree node. @@ -196,11 +197,11 @@ pub struct Page { } /// An exporter for the contents of a single PDF page. -struct PageContext<'a, 'b> { - parent: &'a mut PdfContext<'b>, +pub struct PageContext<'a, 'b> { + pub parent: &'a mut PdfContext<'b>, page_ref: Ref, label: Option, - content: Content, + pub content: Content, state: State, saves: Vec, bottom: f32, @@ -249,21 +250,13 @@ impl PageContext<'_, '_> { let stroke_opacity = stroke .map(|stroke| { let Paint::Solid(color) = stroke.paint; - if let Color::Rgba(rgba_color) = color { - rgba_color.a - } else { - 255 - } + color.alpha().map_or(255, |v| (v * 255.0).round() as u8) }) .unwrap_or(255); let fill_opacity = fill .map(|paint| { let Paint::Solid(color) = paint; - if let Color::Rgba(rgba_color) = color { - rgba_color.a - } else { - 255 - } + color.alpha().map_or(255, |v| (v * 255.0).round() as u8) }) .unwrap_or(255); self.set_external_graphics_state(&ExternalGraphicsState { @@ -296,34 +289,19 @@ impl PageContext<'_, '_> { fn set_fill(&mut self, fill: &Paint) { if self.state.fill.as_ref() != Some(fill) { - let f = |c| c as f32 / 255.0; - let Paint::Solid(color) = fill; - match color { - Color::Luma(c) => { - self.set_fill_color_space(D65_GRAY); - self.content.set_fill_gray(f(c.0)); - } - Color::Rgba(c) => { - self.set_fill_color_space(SRGB); - self.content.set_fill_color([f(c.r), f(c.g), f(c.b)]); - } - Color::Cmyk(c) => { - self.reset_fill_color_space(); - self.content.set_fill_cmyk(f(c.c), f(c.m), f(c.y), f(c.k)); - } - } + fill.set_as_fill(self); self.state.fill = Some(fill.clone()); } } - fn set_fill_color_space(&mut self, space: Name<'static>) { + pub fn set_fill_color_space(&mut self, space: Name<'static>) { if self.state.fill_space != Some(space) { self.content.set_fill_color_space(ColorSpaceOperand::Named(space)); self.state.fill_space = Some(space); } } - fn reset_fill_color_space(&mut self) { + pub fn reset_fill_color_space(&mut self) { self.state.fill_space = None; } @@ -338,22 +316,7 @@ impl PageContext<'_, '_> { miter_limit, } = stroke; - let f = |c| c as f32 / 255.0; - let Paint::Solid(color) = paint; - match color { - Color::Luma(c) => { - self.set_stroke_color_space(D65_GRAY); - self.content.set_stroke_gray(f(c.0)); - } - Color::Rgba(c) => { - self.set_stroke_color_space(SRGB); - self.content.set_stroke_color([f(c.r), f(c.g), f(c.b)]); - } - Color::Cmyk(c) => { - self.reset_stroke_color_space(); - self.content.set_stroke_cmyk(f(c.c), f(c.m), f(c.y), f(c.k)); - } - } + paint.set_as_stroke(self); self.content.set_line_width(thickness.to_f32()); if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) { @@ -379,14 +342,14 @@ impl PageContext<'_, '_> { } } - fn set_stroke_color_space(&mut self, space: Name<'static>) { + pub fn set_stroke_color_space(&mut self, space: Name<'static>) { if self.state.stroke_space != Some(space) { self.content.set_stroke_color_space(ColorSpaceOperand::Named(space)); self.state.stroke_space = Some(space); } } - fn reset_stroke_color_space(&mut self) { + pub fn reset_stroke_color_space(&mut self) { self.state.stroke_space = None; } } diff --git a/crates/typst/src/export/pdf/postscript/hsl.ps b/crates/typst/src/export/pdf/postscript/hsl.ps new file mode 100644 index 000000000..740bc3ed7 --- /dev/null +++ b/crates/typst/src/export/pdf/postscript/hsl.ps @@ -0,0 +1,63 @@ + +{ + % Starting stack: H, S, L + % /!\ WARNING: The hue component **MUST** be encoded + % in the range [0, 1] before calling this function. + % This is because the function assumes that the + % hue component are divided by a factor of 360 + % in order to meet the range requirements of the + % PDF specification. + + % First we do H = (H * 360.0) % 360 + 3 2 roll 360 mul 3 1 roll + + % Compute C = (1 - |2 * L - 1|) * S + dup 1 exch 2 mul 1 sub abs sub 3 2 roll mul + + % P = (H / 60) % 2 + 3 2 roll dup 60 div 2 + 2 copy div cvi mul exch sub abs + + % X = C * (1 - |P - 1|) + 1 exch 1 sub abs sub 3 2 roll dup 3 1 roll mul + + % Compute m = L - C / 2 + exch dup 2 div 5 4 roll exch sub + + % Rotate so H is top + 4 3 roll exch 4 1 roll + + % Construct the RGB stack + dup 60 lt { + % We need to build: (C, X, 0) + pop 0 3 1 roll + } { + dup 120 lt { + % We need to build: (X, C, 0) + pop exch 0 3 1 roll + } { + dup 180 lt { + % We need to build: (0, C, X) + pop 0 + } { + dup 240 lt { + % We need to build: (0, X, C) + pop exch 0 + } { + 300 lt { + % We need to build: (X, 0, C) + 0 3 2 roll + } { + % We need to build: (C, 0, X) + 0 exch + } ifelse + } ifelse + } ifelse + } ifelse + } ifelse + + 4 3 roll + + % Add m to each component + dup dup 6 2 roll add 5 2 roll add exch 4 3 roll add exch +} \ No newline at end of file diff --git a/crates/typst/src/export/pdf/postscript/hsv.ps b/crates/typst/src/export/pdf/postscript/hsv.ps new file mode 100644 index 000000000..b29adf11d --- /dev/null +++ b/crates/typst/src/export/pdf/postscript/hsv.ps @@ -0,0 +1,62 @@ +{ + % Starting stack: H, S, V + % /!\ WARNING: The hue component **MUST** be encoded + % in the range [0, 1] before calling this function. + % This is because the function assumes that the + % hue component are divided by a factor of 360 + % in order to meet the range requirements of the + % PDF specification. + + % First we do H = (H * 360.0) % 360 + 3 2 roll 360 mul 3 1 roll + + % Compute C = V * S + dup 3 1 roll mul + + % P = (H / 60) % 2 + 3 2 roll dup 60 div 2 + 2 copy div cvi mul exch sub abs + + % X = C * (1 - |P - 1|) + 1 exch 1 sub abs sub 3 2 roll dup 3 1 roll mul + + % Compute m = V - C + exch dup 5 4 roll exch sub + + % Rotate so H is top + 4 3 roll exch 4 1 roll + + % Construct the RGB stack + dup 60 lt { + % We need to build: (C, X, 0) + pop 0 3 1 roll + } { + dup 120 lt { + % We need to build: (X, C, 0) + pop exch 0 3 1 roll + } { + dup 180 lt { + % We need to build: (0, C, X) + pop 0 + } { + dup 240 lt { + % We need to build: (0, X, C) + pop exch 0 + } { + 300 lt { + % We need to build: (X, 0, C) + 0 3 2 roll + } { + % We need to build: (C, 0, X) + 0 exch + } ifelse + } ifelse + } ifelse + } ifelse + } ifelse + + 4 3 roll + + % Add m to each component + dup dup 6 2 roll add 5 2 roll add exch 4 3 roll add exch +} \ No newline at end of file diff --git a/crates/typst/src/export/pdf/postscript/oklab.ps b/crates/typst/src/export/pdf/postscript/oklab.ps new file mode 100644 index 000000000..4d6e9ad57 --- /dev/null +++ b/crates/typst/src/export/pdf/postscript/oklab.ps @@ -0,0 +1,78 @@ +{ + % Starting stack: L, A, B + % /!\ WARNING: The A and B components **MUST** be encoded + % in the range [0, 1] before calling this function. + % This is because the function assumes that the + % A and B components are offset by a factor of 0.4 + % in order to meet the range requirements of the + % PDF specification. + + exch 0.4 sub + exch 0.4 sub + + % Load L a and b into the stack + 2 index + 2 index + 2 index + + % Compute f1 = ((0.3963377774 * a) + (0.2158037573 * b) + L)^3 + 0.2158037573 mul exch + 0.3963377774 mul add add + dup dup mul mul + + % Load L, a, and b into the stack + 3 index + 3 index + 3 index + + % Compute f2 = ((-0.1055613458 * a) + (-0.0638541728 * b) + L)^3 + -0.0638541728 mul exch + -0.1055613458 mul add add + dup dup mul mul + + % Load L, a, and b into the stack + 4 index + 4 index + 4 index + + % Compute f3 = ((-0.0894841775 * a) + (-1.2914855480 * b) + L)^3 + -1.2914855480 mul exch + -0.0894841775 mul add add + dup dup mul mul + + % Discard L, a, and b by rolling the stack and popping + 6 3 roll pop pop pop + + % Load f1, f2, and f3 into the stack + 2 index + 2 index + 2 index + + % Compute R = f1 * 4.0767416621 + f2 * -3.3077115913 + f3 * 0.2309699292 + 0.2309699292 mul exch + -3.3077115913 mul add exch + 4.0767416621 mul add + + % Load f1, f2, and f3 into the stack + 3 index + 3 index + 3 index + + % Compute G = f1 * -1.2684380046 + f2 * 2.6097574011 + f3 * -0.3413193965 + -0.3413193965 mul exch + 2.6097574011 mul add exch + -1.2684380046 mul add + + % Load f1, f2, and f3 into the stack + 4 index + 4 index + 4 index + + % Compute B = f1 * -0.0041960863 + f2 * -0.7034186147 + f3 * 1.7076147010 + 1.7076147010 mul exch + -0.7034186147 mul add exch + -0.0041960863 mul add + + % Discard f1, f2, and f3 by rolling the stack and popping + 6 3 roll pop pop pop +} \ No newline at end of file diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index 262bb2e23..c2ae888ed 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -362,7 +362,7 @@ fn render_outline_glyph( let mh = bitmap.height; let Paint::Solid(color) = text.fill; - let c = color.to_rgba(); + let color = sk::ColorU8::from(color); // Pad the pixmap with 1 pixel in each dimension so that we do // not get any problem with floating point errors along their border @@ -370,7 +370,14 @@ fn render_outline_glyph( for x in 0..mw { for y in 0..mh { let alpha = bitmap.coverage[(y * mw + x) as usize]; - let color = sk::ColorU8::from_rgba(c.r, c.g, c.b, alpha).premultiply(); + let color = sk::ColorU8::from_rgba( + color.red(), + color.green(), + color.blue(), + alpha, + ) + .premultiply(); + pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = color; } } @@ -400,9 +407,7 @@ fn render_outline_glyph( // Premultiply the text color. let Paint::Solid(color) = text.fill; - let c = color.to_rgba(); - let color = - bytemuck::cast(sk::ColorU8::from_rgba(c.r, c.g, c.b, 255).premultiply()); + let color = bytemuck::cast(sk::ColorU8::from(color).premultiply()); // Blend the glyph bitmap with the existing pixels on the canvas. let pixels = bytemuck::cast_slice_mut::(canvas.data_mut()); @@ -629,8 +634,8 @@ impl From<&Paint> for sk::Paint<'static> { impl From for sk::Color { fn from(color: Color) -> Self { - let c = color.to_rgba(); - sk::Color::from_rgba8(c.r, c.g, c.b, c.a) + let [r, g, b, a] = color.to_rgba().to_vec4_u8(); + sk::Color::from_rgba8(r, g, b, a) } } @@ -691,6 +696,13 @@ impl AbsExt for Abs { } } +impl From for sk::ColorU8 { + fn from(value: Color) -> Self { + let [r, g, b, _] = value.to_rgba().to_vec4_u8(); + sk::ColorU8::from_rgba(r, g, b, 255) + } +} + // Alpha multiplication and blending are ported from: // https://skia.googlesource.com/skia/+/refs/heads/main/include/core/SkColorPriv.h diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index c68071776..518c40c3a 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -10,8 +10,8 @@ use xmlwriter::XmlWriter; use crate::doc::{Frame, FrameItem, GroupItem, TextItem}; use crate::font::Font; use crate::geom::{ - Abs, Axes, FixedStroke, Geometry, LineCap, LineJoin, Paint, PathItem, Ratio, Shape, - Size, Transform, + Abs, Angle, Axes, Color, FixedStroke, Geometry, LineCap, LineJoin, Paint, PathItem, + Ratio, Shape, Size, Transform, }; use crate::image::{Image, ImageFormat, RasterFormat, VectorFormat}; use crate::util::hash128; @@ -299,13 +299,13 @@ impl SVGRenderer { /// Write a fill attribute. fn write_fill(&mut self, fill: &Paint) { let Paint::Solid(color) = fill; - self.xml.write_attribute("fill", &color.to_rgba().to_hex()); + self.xml.write_attribute("fill", &color.encode()); } /// Write a stroke attribute. fn write_stroke(&mut self, stroke: &FixedStroke) { let Paint::Solid(color) = stroke.paint; - self.xml.write_attribute("stroke", &color.to_rgba().to_hex()); + self.xml.write_attribute("stroke", &color.encode()); self.xml.write_attribute("stroke-width", &stroke.thickness.to_pt()); self.xml.write_attribute( "stroke-linecap", @@ -654,3 +654,74 @@ impl ttf_parser::OutlineBuilder for SvgPathBuilder { write!(&mut self.0, "Z ").unwrap(); } } + +/// Encode the color as an SVG color. +trait ColorEncode { + /// Encode the color. + fn encode(&self) -> EcoString; +} + +impl ColorEncode for Color { + fn encode(&self) -> EcoString { + match *self { + c @ Color::Rgba(_) + | c @ Color::Luma(_) + | c @ Color::Cmyk(_) + | c @ Color::Hsv(_) => c.to_hex(), + Color::LinearRgb(rgb) => { + if rgb.alpha != 1.0 { + eco_format!( + "color(srgb-linear {:.3} {:.3} {:.3} / {:.3})", + rgb.red, + rgb.green, + rgb.blue, + rgb.alpha + ) + } else { + eco_format!( + "color(srgb-linear {:.3} {:.3} {:.3})", + rgb.red, + rgb.green, + rgb.blue, + ) + } + } + Color::Oklab(oklab) => { + if oklab.alpha != 1.0 { + eco_format!( + "oklab({:?} {:.3} {:.3} / {:.3})", + Ratio::new(oklab.l as f64), + oklab.a, + oklab.b, + oklab.alpha + ) + } else { + eco_format!( + "oklab({:?} {:.3} {:.3})", + Ratio::new(oklab.l as f64), + oklab.a, + oklab.b, + ) + } + } + Color::Hsl(hsl) => { + if hsl.alpha != 1.0 { + eco_format!( + "hsla({:?} {:?} {:?} / {:.3})", + Angle::deg(hsl.hue.into_degrees() as f64), + Ratio::new(hsl.saturation as f64), + Ratio::new(hsl.lightness as f64), + hsl.alpha, + ) + } else { + eco_format!( + "hsl({:?} {:?} {:?})", + Angle::deg(hsl.hue.into_degrees() as f64), + Ratio::new(hsl.saturation as f64), + Ratio::new(hsl.lightness as f64), + ) + } + } + } + } +} diff --git a/crates/typst/src/geom/color.rs b/crates/typst/src/geom/color.rs index d3ced8e45..aec0c5cd0 100644 --- a/crates/typst/src/geom/color.rs +++ b/crates/typst/src/geom/color.rs @@ -1,10 +1,21 @@ -use ecow::{eco_format, EcoString}; use std::str::FromStr; +use ecow::{eco_format, EcoString}; +use palette::encoding::{self, Linear}; +use palette::{Darken, Desaturate, FromColor, Lighten, RgbHue, Saturate, ShiftHue}; + use super::*; -use crate::diag::{bail, At, SourceResult}; -use crate::eval::{cast, Args, Array, Cast, Func, Str}; -use crate::syntax::Spanned; +use crate::diag::{bail, error, At, SourceResult}; +use crate::eval::{cast, Args, Array, Str}; +use crate::syntax::{Span, Spanned}; + +// Type aliases for `palette` internal types in f32. +type Oklab = palette::oklab::Oklaba; +type LinearRgba = palette::rgb::Rgba, f32>; +type Rgba = palette::rgb::Rgba; +type Hsl = palette::hsl::Hsla; +type Hsv = palette::hsv::Hsva; +type Luma = palette::luma::Luma; /// A color in a specific color space. /// @@ -12,6 +23,10 @@ use crate::syntax::Spanned; /// - sRGB through the [`rgb` function]($rgb) /// - Device CMYK through [`cmyk` function]($cmyk) /// - D65 Gray through the [`luma` function]($luma) +/// - Oklab through the [`oklab` function]($oklab) +/// - Linear RGB through the [`color.linear-rgb` function]($color.linear-rgb) +/// - HSL through the [`color.hsl` function]($color.hsl) +/// - HSV through the [`color.hsv` function]($color.hsv) /// /// Typst provides the following built-in colors: /// @@ -28,50 +43,52 @@ use crate::syntax::Spanned; /// #rect(fill: color.aqua) /// ``` #[ty(scope)] -#[derive(Copy, Clone, Eq, PartialEq, Hash)] +#[derive(Copy, Clone)] pub enum Color { - /// An 8-bit luma color. - Luma(LumaColor), - /// An 8-bit RGBA color. - Rgba(RgbaColor), - /// An 8-bit CMYK color. - Cmyk(CmykColor), -} - -impl Color { - /// Convert this color to RGBA. - pub fn to_rgba(self) -> RgbaColor { - match self { - Self::Luma(luma) => luma.to_rgba(), - Self::Rgba(rgba) => rgba, - Self::Cmyk(cmyk) => cmyk.to_rgba(), - } - } + /// A 32-bit luma color. + Luma(Luma), + /// A 32-bit L*a*b* color in the Oklab color space. + Oklab(Oklab), + /// A 32-bit RGBA color. + Rgba(Rgba), + /// A 32-bit linear RGB color. + LinearRgb(LinearRgba), + /// A 32-bit CMYK color. + Cmyk(Cmyk), + /// A 32-bit HSL color. + Hsl(Hsl), + /// A 32-bit HSV color. + Hsv(Hsv), } #[scope] impl Color { - pub const BLACK: Self = Self::Rgba(RgbaColor::new(0x00, 0x00, 0x00, 0xFF)); - pub const GRAY: Self = Self::Rgba(RgbaColor::new(0xAA, 0xAA, 0xAA, 0xFF)); - pub const SILVER: Self = Self::Rgba(RgbaColor::new(0xDD, 0xDD, 0xDD, 0xFF)); - pub const WHITE: Self = Self::Rgba(RgbaColor::new(0xFF, 0xFF, 0xFF, 0xFF)); - pub const NAVY: Self = Self::Rgba(RgbaColor::new(0x00, 0x1f, 0x3f, 0xFF)); - pub const BLUE: Self = Self::Rgba(RgbaColor::new(0x00, 0x74, 0xD9, 0xFF)); - pub const AQUA: Self = Self::Rgba(RgbaColor::new(0x7F, 0xDB, 0xFF, 0xFF)); - pub const TEAL: Self = Self::Rgba(RgbaColor::new(0x39, 0xCC, 0xCC, 0xFF)); - pub const EASTERN: Self = Self::Rgba(RgbaColor::new(0x23, 0x9D, 0xAD, 0xFF)); - pub const PURPLE: Self = Self::Rgba(RgbaColor::new(0xB1, 0x0D, 0xC9, 0xFF)); - pub const FUCHSIA: Self = Self::Rgba(RgbaColor::new(0xF0, 0x12, 0xBE, 0xFF)); - pub const MAROON: Self = Self::Rgba(RgbaColor::new(0x85, 0x14, 0x4b, 0xFF)); - pub const RED: Self = Self::Rgba(RgbaColor::new(0xFF, 0x41, 0x36, 0xFF)); - pub const ORANGE: Self = Self::Rgba(RgbaColor::new(0xFF, 0x85, 0x1B, 0xFF)); - pub const YELLOW: Self = Self::Rgba(RgbaColor::new(0xFF, 0xDC, 0x00, 0xFF)); - pub const OLIVE: Self = Self::Rgba(RgbaColor::new(0x3D, 0x99, 0x70, 0xFF)); - pub const GREEN: Self = Self::Rgba(RgbaColor::new(0x2E, 0xCC, 0x40, 0xFF)); - pub const LIME: Self = Self::Rgba(RgbaColor::new(0x01, 0xFF, 0x70, 0xFF)); + pub const BLACK: Self = Self::Luma(Luma::new(0.0)); + pub const GRAY: Self = Self::Luma(Luma::new(0.6666666)); + pub const WHITE: Self = Self::Luma(Luma::new(1.0)); + pub const SILVER: Self = Self::Luma(Luma::new(0.8666667)); + pub const NAVY: Self = Self::Rgba(Rgba::new(0.0, 0.121569, 0.247059, 1.0)); + pub const BLUE: Self = Self::Rgba(Rgba::new(0.0, 0.454902, 0.85098, 1.0)); + pub const AQUA: Self = Self::Rgba(Rgba::new(0.4980392, 0.858823, 1.0, 1.0)); + pub const TEAL: Self = Self::Rgba(Rgba::new(0.223529, 0.8, 0.8, 1.0)); + pub const EASTERN: Self = Self::Rgba(Rgba::new(0.13725, 0.615686, 0.678431, 1.0)); + pub const PURPLE: Self = Self::Rgba(Rgba::new(0.694118, 0.050980, 0.788235, 1.0)); + pub const FUCHSIA: Self = Self::Rgba(Rgba::new(0.941177, 0.070588, 0.745098, 1.0)); + pub const MAROON: Self = Self::Rgba(Rgba::new(0.521569, 0.078431, 0.294118, 1.0)); + pub const RED: Self = Self::Rgba(Rgba::new(1.0, 0.254902, 0.211765, 1.0)); + pub const ORANGE: Self = Self::Rgba(Rgba::new(1.0, 0.521569, 0.105882, 1.0)); + pub const YELLOW: Self = Self::Rgba(Rgba::new(1.0, 0.8627451, 0.0, 1.0)); + pub const OLIVE: Self = Self::Rgba(Rgba::new(0.239216, 0.6, 0.4392157, 1.0)); + pub const GREEN: Self = Self::Rgba(Rgba::new(0.1803922, 0.8, 0.2509804, 1.0)); + pub const LIME: Self = Self::Rgba(Rgba::new(0.0039216, 1.0, 0.4392157, 1.0)); /// Create a grayscale color. /// + /// A grayscale color is represented internally by a single `lightness` component. + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// /// ```example /// #for x in range(250, step: 50) { /// box(square(fill: luma(x))) @@ -79,16 +96,158 @@ impl Color { /// ``` #[func] pub fn luma( - /// The gray component. - gray: Component, - ) -> Color { - LumaColor::new(gray.0).into() + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, + /// The lightness component. + #[external] + lightness: Component, + /// The color to convert to grayscale. + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_luma() + } else { + let Component(gray) = + args.expect("gray component").unwrap_or(Component(Ratio::one())); + Self::Luma(Luma::new(gray.get() as f32)) + }) + } + + /// Create an [Oklab](https://bottosson.github.io/posts/oklab/) color. + /// + /// This color space is well suited for the following use cases: + /// - Color manipulation such as saturating while keeping perceived hue + /// - Creating grayscale images with uniform perceived lightness + /// - Creating smooth and uniform color transition and gradients + /// + /// A linear Oklab color is represented internally by an array of four components: + /// - lightness ([`ratio`]($ratio)) + /// - a ([`float`]($float) in the range `[-0.4..0.4]`) + /// - b ([`float`]($float) in the range `[-0.4..0.4]`) + /// - alpha ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// + /// ```example + /// #square( + /// fill: oklab(27%, 20%, -3%, 50%) + /// ) + /// ``` + #[func] + pub fn oklab( + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, + /// The cyan component. + #[external] + lightness: RatioComponent, + /// The magenta component. + #[external] + a: ABComponent, + /// The yellow component. + #[external] + b: ABComponent, + /// The key component. + #[external] + alpha: RatioComponent, + /// The color to convert to Oklab. + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_oklab() + } else { + let RatioComponent(l) = args.expect("lightness component")?; + let ABComponent(a) = args.expect("A component")?; + let ABComponent(b) = args.expect("B component")?; + let RatioComponent(alpha) = + args.eat()?.unwrap_or(RatioComponent(Ratio::one())); + Self::Oklab(Oklab::new( + l.get() as f32, + a.get() as f32, + b.get() as f32, + alpha.get() as f32, + )) + }) + } + + /// Create an RGB(A) color with linear luma. + /// + /// This color space is similar to Srgb, but with the distinction that + /// the color component are not gamma corrected. This makes it easier to + /// perform color operations such as blending and interpolation. Although, + /// you should prefer to use the [`oklab` function]($oklab) for these. + /// + /// A linear RGB(A) color is represented internally by an array of four components: + /// - red ([`ratio`]($ratio)) + /// - green ([`ratio`]($ratio)) + /// - blue ([`ratio`]($ratio)) + /// - alpha ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// + /// ```example + /// #square( + /// fill: color.linear-rgb(30%, 50%, 10%) + /// ) + /// ``` + #[func(title = "Linear RGB")] + pub fn linear_rgb( + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, + /// The red component. + #[external] + red: Component, + /// The green component. + #[external] + green: Component, + /// The blue component. + #[external] + blue: Component, + /// The alpha component. + #[external] + alpha: Component, + /// The color to convert to linear RGB(A). + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_linear_rgb() + } else { + let Component(r) = args.expect("red component")?; + let Component(g) = args.expect("green component")?; + let Component(b) = args.expect("blue component")?; + let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one())); + Self::LinearRgb(LinearRgba::new( + r.get() as f32, + g.get() as f32, + b.get() as f32, + a.get() as f32, + )) + }) } /// Create an RGB(A) color. /// /// The color is specified in the sRGB color space. /// + /// An RGB(A) color is represented internally by an array of four components: + /// - red ([`ratio`]($ratio)) + /// - green ([`ratio`]($ratio)) + /// - blue ([`ratio`]($ratio)) + /// - alpha ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// /// ```example /// #square(fill: rgb("#b1f2eb")) /// #square(fill: rgb(87, 127, 230)) @@ -125,16 +284,26 @@ impl Color { /// The alpha component. #[external] alpha: Component, + /// The color to convert to RGB(A). + #[external] + color: Color, ) -> SourceResult { let mut args = args; Ok(if let Some(string) = args.find::>()? { - RgbaColor::from_str(&string.v).at(string.span)?.into() + Self::from_str(&string.v).at(string.span)? + } else if let Some(color) = args.find::()? { + color.to_rgba() } else { let Component(r) = args.expect("red component")?; let Component(g) = args.expect("green component")?; let Component(b) = args.expect("blue component")?; - let Component(a) = args.eat()?.unwrap_or(Component(255)); - RgbaColor::new(r, g, b, a).into() + let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one())); + Self::Rgba(Rgba::new( + r.get() as f32, + g.get() as f32, + b.get() as f32, + a.get() as f32, + )) }) } @@ -144,6 +313,15 @@ impl Color { /// to RGB for display preview might differ from how your printer reproduces /// the color. /// + /// An HSL color is represented internally by an array of four components: + /// - cyan ([`ratio`]($ratio)) + /// - magenta ([`ratio`]($ratio)) + /// - yellow ([`ratio`]($ratio)) + /// - key ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// /// ```example /// #square( /// fill: cmyk(27%, 0%, 3%, 5%) @@ -151,31 +329,305 @@ impl Color { /// ``` #[func(title = "CMYK")] pub fn cmyk( + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, /// The cyan component. + #[external] cyan: RatioComponent, /// The magenta component. + #[external] magenta: RatioComponent, /// The yellow component. + #[external] yellow: RatioComponent, /// The key component. + #[external] key: RatioComponent, - ) -> Color { - CmykColor::new(cyan.0, magenta.0, yellow.0, key.0).into() + /// The color to convert to CMYK. + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_cmyk() + } else { + let RatioComponent(c) = args.expect("cyan component")?; + let RatioComponent(m) = args.expect("magenta component")?; + let RatioComponent(y) = args.expect("yellow component")?; + let RatioComponent(k) = args.expect("key/black component")?; + Self::Cmyk(Cmyk::new( + c.get() as f32, + m.get() as f32, + y.get() as f32, + k.get() as f32, + )) + }) } - /// Returns the constructor function for this color's kind - /// ([`rgb`]($color.rgb), [`cmyk`]($color.cmyk) or [`luma`]($color.luma)). + /// Create an HSL color. + /// + /// This color space is useful for specifying colors by hue, saturation and + /// lightness. It is also useful for color manipulation, such as saturating + /// while keeping perceived hue. + /// + /// An HSL color is represented internally by an array of four components: + /// - hue ([`angle`]($angle)) + /// - saturation ([`ratio`]($ratio)) + /// - lightness ([`ratio`]($ratio)) + /// - alpha ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// + /// ```example + /// #square( + /// fill: color.hsl(30deg, 50%, 60%) + /// ) + /// ``` + #[func(title = "HSL")] + pub fn hsl( + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, + /// The hue angle. + #[external] + hue: Angle, + /// The saturation component. + #[external] + saturation: Component, + /// The lightness component. + #[external] + lightness: Component, + /// The alpha component. + #[external] + alpha: Component, + /// The color to convert to HSL. + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_hsl() + } else { + let h: Angle = args.expect("hue component")?; + let Component(s) = args.expect("saturation component")?; + let Component(l) = args.expect("lightness component")?; + let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one())); + Self::Hsl(Hsl::new( + RgbHue::from_degrees(h.to_deg() as f32), + s.get() as f32, + l.get() as f32, + a.get() as f32, + )) + }) + } + + /// Create an HSV color. + /// + /// This color space is useful for specifying colors by hue, saturation and + /// value. It is also useful for color manipulation, such as saturating + /// while keeping perceived hue. + /// + /// An HSV color is represented internally by an array of four components: + /// - hue ([`angle`]($angle)) + /// - saturation ([`ratio`]($ratio)) + /// - value ([`ratio`]($ratio)) + /// - alpha ([`ratio`]($ratio)) + /// + /// These components are also available using the [`components`]($color.components) + /// method. + /// + /// ```example + /// #square( + /// fill: color.hsv(30deg, 50%, 60%) + /// ) + /// ``` + #[func(title = "HSV")] + pub fn hsv( + /// The real arguments (the other arguments are just for the docs, this + /// function is a bit involved, so we parse the arguments manually). + args: Args, + /// The hue angle. + #[external] + hue: Angle, + /// The saturation component. + #[external] + saturation: Component, + /// The value component. + #[external] + value: Component, + /// The alpha component. + #[external] + alpha: Component, + /// The color to convert to HSL. + #[external] + color: Color, + ) -> SourceResult { + let mut args = args; + Ok(if let Some(color) = args.find::()? { + color.to_hsv() + } else { + let h: Angle = args.expect("hue component")?; + let Component(s) = args.expect("saturation component")?; + let Component(v) = args.expect("value component")?; + let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one())); + Self::Hsv(Hsv::new( + RgbHue::from_degrees(h.to_deg() as f32), + s.get() as f32, + v.get() as f32, + a.get() as f32, + )) + }) + } + + /// Converts this color into its components. + /// + /// The size and values of this array depends on the color space. You can + /// obtain the color space using [`space`]($color.space). Below is a table + /// of the color spaces and their components: + /// + /// | Color space | C1 | C2 | C3 | C4 | + /// |-------------------------|-----------|------------|-----------|--------| + /// | [`luma`]($color.luma) | Lightness | | | | + /// | [`oklab`]($color.oklab) | Lightness | `a` | `b` | Alpha | + /// | [`linear-rgb`]($color.linear-rgb) | Red | Green | Blue | Alpha | + /// | [`rgb`]($color.rgb) | Red | Green | Blue | Alpha | + /// | [`cmyk`]($color.cmyk) | Cyan | Magenta | Yellow | Key | + /// | [`hsl`]($color.hsl) | Hue | Saturation | Lightness | Alpha | + /// | [`hsv`]($color.hsv) | Hue | Saturation | Value | Alpha | + /// + /// For the meaning and type of each individual value, see the documentation + /// of the corresponding color space. The alpha component is optional and + /// only included if the `alpha` argument is `true`. The length of the + /// returned array depends on the number of components and whether the alpha + /// component is included. + /// + /// ```example + /// // note that the alpha component is included by default + /// #(rgb(40%, 60%, 80%).components() == (40%, 60%, 80%, 100%)) + /// ``` + #[func] + pub fn components( + self, + /// Whether to include the alpha component. + #[default(true)] + alpha: bool, + ) -> Array { + match self { + Self::Luma(c) => array![Ratio::new(c.luma as _)], + Self::Oklab(c) => { + if alpha { + array![ + Ratio::new(c.l as _), + (c.a as f64 * 1000.0).round() / 1000.0, + (c.b as f64 * 1000.0).round() / 1000.0, + Ratio::new(c.alpha as _), + ] + } else { + array![ + Ratio::new(c.l as _), + (c.a as f64 * 1000.0).round() / 1000.0, + (c.b as f64 * 1000.0).round() / 1000.0, + ] + } + } + Self::LinearRgb(c) => { + if alpha { + array![ + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + Ratio::new(c.alpha as _), + ] + } else { + array![ + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + ] + } + } + Self::Rgba(c) => { + if alpha { + array![ + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + Ratio::new(c.alpha as _), + ] + } else { + array![ + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + ] + } + } + Self::Cmyk(c) => array![ + Ratio::new(c.c as _), + Ratio::new(c.m as _), + Ratio::new(c.y as _), + Ratio::new(c.k as _), + ], + Self::Hsl(c) => { + if alpha { + array![ + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.lightness as _), + Ratio::new(c.alpha as _), + ] + } else { + array![ + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.lightness as _), + ] + } + } + Self::Hsv(c) => { + if alpha { + array![ + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.value as _), + Ratio::new(c.alpha as _), + ] + } else { + array![ + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.value as _), + ] + } + } + } + } + + /// Returns the constructor function for this color's space: + /// - [`oklab`]($color.oklab) + /// - [`luma`]($color.luma) + /// - [`linear-rgb`]($color.linear-rgb) + /// - [`rgb`]($color.rgb) + /// - [`cmyk`]($color.cmyk) + /// - [`hsl`]($color.hsl) + /// - [`hsv`]($color.hsv) /// /// ```example /// #let color = cmyk(1%, 2%, 3%, 4%) - /// #(color.kind() == cmyk) + /// #(color.space() == cmyk) /// ``` #[func] - pub fn kind(self) -> Func { + pub fn space(self) -> ColorSpace { match self { - Self::Luma(_) => Self::luma_data().into(), - Self::Rgba(_) => Self::rgb_data().into(), - Self::Cmyk(_) => Self::cmyk_data().into(), + Self::Luma(_) => ColorSpace::D65Gray, + Self::Oklab(_) => ColorSpace::Oklab, + Self::LinearRgb(_) => ColorSpace::LinearRgb, + Self::Rgba(_) => ColorSpace::Srgb, + Self::Cmyk(_) => ColorSpace::Cmyk, + Self::Hsl(_) => ColorSpace::Hsl, + Self::Hsv(_) => ColorSpace::Hsv, } } @@ -184,44 +636,11 @@ impl Color { /// omitted if it is equal to `ff` (255 / 100%). #[func] pub fn to_hex(self) -> EcoString { - self.to_rgba().to_hex() - } - - /// Converts this color to sRGB and returns its components (R, G, B, A) as - /// an array of [integers]($int). - #[func(name = "to-rgba")] - pub fn to_rgba_array(self) -> Array { - self.to_rgba().to_array() - } - - /// Converts this color to Digital CMYK and returns its components - /// (C, M, Y, K) as an array of [ratios]($ratio). Note that this function - /// will throw an error when applied to an [rgb]($rgb) color, since its - /// conversion to CMYK is not available. - #[func] - pub fn to_cmyk(self) -> StrResult { - match self { - Self::Luma(luma) => Ok(luma.to_cmyk().to_array()), - Self::Rgba(_) => { - bail!("cannot obtain cmyk values from rgba color") - } - Self::Cmyk(cmyk) => Ok(cmyk.to_array()), - } - } - - /// If this color was created with [luma]($luma), returns the - /// [integer]($int) value used on construction. Otherwise (for [rgb]($rgb) - /// and [cmyk]($cmyk) colors), throws an error. - #[func] - pub fn to_luma(self) -> StrResult { - match self { - Self::Luma(luma) => Ok(luma.0), - Self::Rgba(_) => { - bail!("cannot obtain the luma value of rgba color") - } - Self::Cmyk(_) => { - bail!("cannot obtain the luma value of cmyk color") - } + let [r, g, b, a] = self.to_rgba().to_vec4_u8(); + if a != 255 { + eco_format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a) + } else { + eco_format!("#{:02x}{:02x}{:02x}", r, g, b) } } @@ -232,10 +651,15 @@ impl Color { /// The factor to lighten the color by. factor: Ratio, ) -> Color { + let factor = factor.get() as f32; match self { - Self::Luma(luma) => Self::Luma(luma.lighten(factor)), - Self::Rgba(rgba) => Self::Rgba(rgba.lighten(factor)), - Self::Cmyk(cmyk) => Self::Cmyk(cmyk.lighten(factor)), + Self::Luma(c) => Self::Luma(c.lighten(factor)), + Self::Oklab(c) => Self::Oklab(c.lighten(factor)), + Self::LinearRgb(c) => Self::LinearRgb(c.lighten(factor)), + Self::Rgba(c) => Self::Rgba(c.lighten(factor)), + Self::Cmyk(c) => Self::Cmyk(c.lighten(factor)), + Self::Hsl(c) => Self::Hsl(c.lighten(factor)), + Self::Hsv(c) => Self::Hsv(c.lighten(factor)), } } @@ -246,29 +670,124 @@ impl Color { /// The factor to darken the color by. factor: Ratio, ) -> Color { + let factor = factor.get() as f32; match self { - Self::Luma(luma) => Self::Luma(luma.darken(factor)), - Self::Rgba(rgba) => Self::Rgba(rgba.darken(factor)), - Self::Cmyk(cmyk) => Self::Cmyk(cmyk.darken(factor)), + Self::Luma(c) => Self::Luma(c.darken(factor)), + Self::Oklab(c) => Self::Oklab(c.darken(factor)), + Self::LinearRgb(c) => Self::LinearRgb(c.darken(factor)), + Self::Rgba(c) => Self::Rgba(c.darken(factor)), + Self::Cmyk(c) => Self::Cmyk(c.darken(factor)), + Self::Hsl(c) => Self::Hsl(c.darken(factor)), + Self::Hsv(c) => Self::Hsv(c.darken(factor)), } } + /// Increases the saturation of a color by a given factor. + #[func] + pub fn saturate( + self, + /// The call span + span: Span, + /// The factor to saturate the color by. + factor: Ratio, + ) -> SourceResult { + Ok(match self { + Self::Luma(_) => { + bail!(error!(span, "cannot saturate grayscale color") + .with_hint("try converting your color to RGB first")); + } + Self::Oklab(_) => self.to_hsv().saturate(span, factor)?.to_oklab(), + Self::LinearRgb(_) => self.to_hsv().saturate(span, factor)?.to_linear_rgb(), + Self::Rgba(_) => self.to_hsv().saturate(span, factor)?.to_rgba(), + Self::Cmyk(_) => self.to_hsv().saturate(span, factor)?.to_cmyk(), + Self::Hsl(c) => Self::Hsl(c.saturate(factor.get() as f32)), + Self::Hsv(c) => Self::Hsv(c.saturate(factor.get() as f32)), + }) + } + + /// Decreases the saturation of a color by a given factor. + #[func] + pub fn desaturate( + self, + /// The call span + span: Span, + /// The factor to desaturate the color by. + factor: Ratio, + ) -> SourceResult { + Ok(match self { + Self::Luma(_) => { + bail!(error!(span, "cannot desaturate grayscale color") + .with_hint("try converting your color to RGB first")); + } + Self::Oklab(_) => self.to_hsv().desaturate(span, factor)?.to_oklab(), + Self::LinearRgb(_) => self.to_hsv().desaturate(span, factor)?.to_linear_rgb(), + Self::Rgba(_) => self.to_hsv().desaturate(span, factor)?.to_rgba(), + Self::Cmyk(_) => self.to_hsv().desaturate(span, factor)?.to_cmyk(), + Self::Hsl(c) => Self::Hsl(c.desaturate(factor.get() as f32)), + Self::Hsv(c) => Self::Hsv(c.desaturate(factor.get() as f32)), + }) + } + /// Produces the negative of the color. #[func] pub fn negate(self) -> Color { match self { - Self::Luma(luma) => Self::Luma(luma.negate()), - Self::Rgba(rgba) => Self::Rgba(rgba.negate()), - Self::Cmyk(cmyk) => Self::Cmyk(cmyk.negate()), + Self::Luma(c) => Self::Luma(Luma::new(1.0 - c.luma)), + Self::Oklab(c) => Self::Oklab(Oklab::new(c.l, 1.0 - c.a, 1.0 - c.b, c.alpha)), + Self::LinearRgb(c) => Self::LinearRgb(LinearRgba::new( + 1.0 - c.red, + 1.0 - c.green, + 1.0 - c.blue, + c.alpha, + )), + Self::Rgba(c) => { + Self::Rgba(Rgba::new(1.0 - c.red, 1.0 - c.green, 1.0 - c.blue, c.alpha)) + } + Self::Cmyk(c) => Self::Cmyk(Cmyk::new(1.0 - c.c, 1.0 - c.m, 1.0 - c.y, c.k)), + Self::Hsl(c) => Self::Hsl(Hsl::new( + RgbHue::from_degrees(360.0 - c.hue.into_degrees()), + c.saturation, + c.lightness, + c.alpha, + )), + Self::Hsv(c) => Self::Hsv(Hsv::new( + RgbHue::from_degrees(360.0 - c.hue.into_degrees()), + c.saturation, + c.value, + c.alpha, + )), } } + /// Rotates the hue of the color by a given angle. + #[func] + pub fn rotate( + self, + /// The call span + span: Span, + /// The angle to rotate the hue by. + angle: Angle, + ) -> SourceResult { + Ok(match self { + Self::Luma(_) => { + bail!(error!(span, "cannot rotate grayscale color") + .with_hint("try converting your color to RGB first")); + } + Self::Oklab(_) => self.to_hsv().rotate(span, angle)?.to_oklab(), + Self::LinearRgb(_) => self.to_hsv().rotate(span, angle)?.to_linear_rgb(), + Self::Rgba(_) => self.to_hsv().rotate(span, angle)?.to_rgba(), + Self::Cmyk(_) => self.to_hsv().rotate(span, angle)?.to_cmyk(), + Self::Hsl(c) => Self::Hsl(c.shift_hue(angle.to_deg() as f32)), + Self::Hsv(c) => Self::Hsv(c.shift_hue(angle.to_deg() as f32)), + }) + } + /// Create a color by mixing two or more colors. /// /// ```example /// #set block(height: 20pt, width: 100%) /// #block(fill: red.mix(blue)) - /// #block(fill: red.mix(blue, space: "srgb")) + /// #block(fill: red.mix(blue, space: rgb)) /// #block(fill: color.mix(red, blue, white)) /// #block(fill: color.mix((red, 70%), (blue, 30%))) /// ``` @@ -282,7 +801,7 @@ impl Color { #[variadic] colors: Vec, /// The color space to mix in. By default, this happens in a perceptual - /// color space (Oklab). + /// color space ([`oklab`]($color.oklab)). #[named] #[default(ColorSpace::Oklab)] space: ColorSpace, @@ -290,8 +809,9 @@ impl Color { let mut total = 0.0; let mut acc = [0.0; 4]; - for WeightedColor(color, weight) in colors.into_iter() { - let v = rgba_to_vec4(color.to_rgba(), space); + for WeightedColor { color, weight } in colors.into_iter() { + let weight = weight as f32; + let v = color.to_space(space).to_vec4(); acc[0] += weight * v[0]; acc[1] += weight * v[1]; acc[2] += weight * v[2]; @@ -303,218 +823,321 @@ impl Color { bail!("sum of weights must be positive"); } - let mixed = acc.map(|v| v / total); - Ok(vec4_to_rgba(mixed, space).into()) + let m = acc.map(|v| v / total); + Ok(match space { + ColorSpace::Oklab => Color::Oklab(Oklab::new(m[0], m[1], m[2], m[3])), + ColorSpace::Srgb => Color::Rgba(Rgba::new(m[0], m[1], m[2], m[3])), + ColorSpace::LinearRgb => { + Color::LinearRgb(LinearRgba::new(m[0], m[1], m[2], m[3])) + } + ColorSpace::Hsl => { + Color::Hsl(Hsl::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3])) + } + ColorSpace::Hsv => { + Color::Hsv(Hsv::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3])) + } + ColorSpace::Cmyk => Color::Cmyk(Cmyk::new(m[0], m[1], m[2], m[3])), + ColorSpace::D65Gray => Color::Luma(Luma::new(m[0])), + }) + } +} + +impl Color { + /// Construct a new RGBA color from 8-bit values. + pub fn from_u8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self::Rgba(Rgba::new( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + a as f32 / 255.0, + )) + } + + /// Converts a 32-bit integer to an RGBA color. + #[inline] + pub fn from_u32(color: u32) -> Self { + Self::from_u8( + ((color >> 24) & 0xFF) as u8, + ((color >> 16) & 0xFF) as u8, + ((color >> 8) & 0xFF) as u8, + (color & 0xFF) as u8, + ) + } + + pub fn alpha(&self) -> Option { + match self { + Color::Luma(_) | Color::Cmyk(_) => None, + Color::Oklab(c) => Some(c.alpha), + Color::Rgba(c) => Some(c.alpha), + Color::LinearRgb(c) => Some(c.alpha), + Color::Hsl(c) => Some(c.alpha), + Color::Hsv(c) => Some(c.alpha), + } + } + + pub fn with_alpha(mut self, alpha: f32) -> Self { + match &mut self { + Color::Luma(_) | Color::Cmyk(_) => {} + Color::Oklab(c) => c.alpha = alpha, + Color::Rgba(c) => c.alpha = alpha, + Color::LinearRgb(c) => c.alpha = alpha, + Color::Hsl(c) => c.alpha = alpha, + Color::Hsv(c) => c.alpha = alpha, + } + + self + } + + pub fn to_vec4(&self) -> [f32; 4] { + match self { + Color::Luma(c) => [c.luma; 4], + Color::Oklab(c) => [c.l, c.a, c.b, c.alpha], + Color::Rgba(c) => [c.red, c.green, c.blue, c.alpha], + Color::LinearRgb(c) => [c.red, c.green, c.blue, c.alpha], + Color::Cmyk(c) => [c.c, c.m, c.y, c.k], + Color::Hsl(c) => [ + c.hue.into_degrees().rem_euclid(360.0), + c.saturation, + c.lightness, + c.alpha, + ], + Color::Hsv(c) => { + [c.hue.into_degrees().rem_euclid(360.0), c.saturation, c.value, c.alpha] + } + } + } + + pub fn to_vec4_u8(&self) -> [u8; 4] { + self.to_vec4().map(|x| (x * 255.0).round() as u8) + } + + pub fn to_space(self, space: ColorSpace) -> Self { + match space { + ColorSpace::Oklab => self.to_oklab(), + ColorSpace::Srgb => self.to_rgba(), + ColorSpace::LinearRgb => self.to_linear_rgb(), + ColorSpace::Hsl => self.to_hsl(), + ColorSpace::Hsv => self.to_hsv(), + ColorSpace::Cmyk => self.to_cmyk(), + ColorSpace::D65Gray => self.to_luma(), + } + } + + pub fn to_luma(self) -> Self { + Self::Luma(match self { + Self::Luma(c) => c, + Self::Oklab(c) => Luma::from_color(c), + Self::Rgba(c) => Luma::from_color(c), + Self::LinearRgb(c) => Luma::from_color(c), + Self::Cmyk(c) => Luma::from_color(c.to_rgba()), + Self::Hsl(c) => Luma::from_color(c), + Self::Hsv(c) => Luma::from_color(c), + }) + } + + pub fn to_oklab(self) -> Self { + Self::Oklab(match self { + Self::Luma(c) => Oklab::from_color(c), + Self::Oklab(c) => c, + Self::Rgba(c) => Oklab::from_color(c), + Self::LinearRgb(c) => Oklab::from_color(c), + Self::Cmyk(c) => Oklab::from_color(c.to_rgba()), + Self::Hsl(c) => Oklab::from_color(c), + Self::Hsv(c) => Oklab::from_color(c), + }) + } + + pub fn to_linear_rgb(self) -> Self { + Self::LinearRgb(match self { + Self::Luma(c) => LinearRgba::from_color(c), + Self::Oklab(c) => LinearRgba::from_color(c), + Self::Rgba(c) => LinearRgba::from_color(c), + Self::LinearRgb(c) => c, + Self::Cmyk(c) => LinearRgba::from_color(c.to_rgba()), + Self::Hsl(c) => LinearRgba::from_color(Rgba::from_color(c)), + Self::Hsv(c) => LinearRgba::from_color(Rgba::from_color(c)), + }) + } + + pub fn to_rgba(self) -> Self { + Self::Rgba(match self { + Self::Luma(c) => Rgba::from_color(c), + Self::Oklab(c) => Rgba::from_color(c), + Self::Rgba(c) => c, + Self::LinearRgb(c) => Rgba::from_linear(c), + Self::Cmyk(c) => c.to_rgba(), + Self::Hsl(c) => Rgba::from_color(c), + Self::Hsv(c) => Rgba::from_color(c), + }) + } + + pub fn to_cmyk(self) -> Self { + Self::Cmyk(match self { + Self::Luma(c) => Cmyk::from_luma(c), + Self::Oklab(c) => Cmyk::from_rgba(Rgba::from_color(c)), + Self::Rgba(c) => Cmyk::from_rgba(c), + Self::LinearRgb(c) => Cmyk::from_rgba(Rgba::from_linear(c)), + Self::Cmyk(c) => c, + Self::Hsl(c) => Cmyk::from_rgba(Rgba::from_color(c)), + Self::Hsv(c) => Cmyk::from_rgba(Rgba::from_color(c)), + }) + } + + pub fn to_hsl(self) -> Self { + Self::Hsl(match self { + Self::Luma(c) => Hsl::from_color(c), + Self::Oklab(c) => Hsl::from_color(c), + Self::Rgba(c) => Hsl::from_color(c), + Self::LinearRgb(c) => Hsl::from_color(Rgba::from_linear(c)), + Self::Cmyk(c) => Hsl::from_color(c.to_rgba()), + Self::Hsl(c) => c, + Self::Hsv(c) => Hsl::from_color(c), + }) + } + + pub fn to_hsv(self) -> Self { + Self::Hsv(match self { + Self::Luma(c) => Hsv::from_color(c), + Self::Oklab(c) => Hsv::from_color(c), + Self::Rgba(c) => Hsv::from_color(c), + Self::LinearRgb(c) => Hsv::from_color(Rgba::from_linear(c)), + Self::Cmyk(c) => Hsv::from_color(c.to_rgba()), + Self::Hsl(c) => Hsv::from_color(c), + Self::Hsv(c) => c, + }) } } impl Debug for Color { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { - Self::Luma(c) => Debug::fmt(c, f), - Self::Rgba(c) => Debug::fmt(c, f), - Self::Cmyk(c) => Debug::fmt(c, f), - } - } -} - -/// A color with a weight. -pub struct WeightedColor(Color, f32); - -cast! { - WeightedColor, - v: Color => Self(v, 1.0), - v: Array => { - let mut iter = v.into_iter(); - match (iter.next(), iter.next(), iter.next()) { - (Some(c), Some(w), None) => Self(c.cast()?, w.cast::()?.0), - _ => bail!("expected a color or color-weight pair"), - } - } -} - -/// A weight for color mixing. -struct Weight(f32); - -cast! { - Weight, - v: f64 => Self(v as f32), - v: Ratio => Self(v.get() as f32), -} - -/// Convert an RGBA color to four components in the given color space. -fn rgba_to_vec4(color: RgbaColor, space: ColorSpace) -> [f32; 4] { - match space { - ColorSpace::Oklab => { - let RgbaColor { r, g, b, a } = color; - let oklab = oklab::srgb_to_oklab(oklab::RGB { r, g, b }); - [oklab.l, oklab.a, oklab.b, a as f32 / 255.0] - } - ColorSpace::Srgb => { - let RgbaColor { r, g, b, a } = color; - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0] - } - } -} - -/// Convert four components in the given color space to RGBA. -fn vec4_to_rgba(vec: [f32; 4], space: ColorSpace) -> RgbaColor { - match space { - ColorSpace::Oklab => { - let [l, a, b, alpha] = vec; - let oklab::RGB { r, g, b } = oklab::oklab_to_srgb(oklab::Oklab { l, a, b }); - RgbaColor { r, g, b, a: (alpha * 255.0).round() as u8 } - } - ColorSpace::Srgb => { - let [r, g, b, a] = vec; - RgbaColor { - r: (r * 255.0).round() as u8, - g: (g * 255.0).round() as u8, - b: (b * 255.0).round() as u8, - a: (a * 255.0).round() as u8, + Self::Luma(c) => write!(f, "luma({:?})", Ratio::new(c.luma as _)), + Self::Rgba(_) => write!(f, "rgb({:?})", self.to_hex()), + Self::LinearRgb(c) => { + if c.alpha == 1.0 { + write!( + f, + "color.linear-rgb({:?}, {:?}, {:?})", + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + ) + } else { + write!( + f, + "color.linear-rgb({:?}, {:?}, {:?}, {:?})", + Ratio::new(c.red as _), + Ratio::new(c.green as _), + Ratio::new(c.blue as _), + Ratio::new(c.alpha as _), + ) + } + } + Self::Cmyk(c) => { + write!( + f, + "rgb({:?}, {:?}, {:?}, {:?})", + Ratio::new(c.c as _), + Ratio::new(c.m as _), + Ratio::new(c.y as _), + Ratio::new(c.k as _), + ) + } + Self::Oklab(c) => { + if c.alpha == 1.0 { + write!( + f, + "oklab({:?}, {:.3}, {:.3})", + Ratio::new(c.l as _), + (c.a * 1000.0).round() / 1000.0, + (c.b * 1000.0).round() / 1000.0, + ) + } else { + write!( + f, + "oklab({:?}, {:?}, {:?}, {:?})", + Ratio::new(c.l as _), + (c.a * 1000.0).round() / 1000.0, + (c.b * 1000.0).round() / 1000.0, + Ratio::new(c.alpha as _), + ) + } + } + Self::Hsl(c) => { + if c.alpha == 1.0 { + write!( + f, + "color.hsl({:?}, {:?}, {:?})", + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.lightness as _), + ) + } else { + write!( + f, + "color.hsl({:?}, {:?}, {:?}, {:?})", + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.lightness as _), + Ratio::new(c.alpha as _), + ) + } + } + Self::Hsv(c) => { + if c.alpha == 1.0 { + write!( + f, + "color.hsv({:?}, {:?}, {:?})", + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.value as _), + ) + } else { + write!( + f, + "color.hsv({:?}, {:?}, {:?}, {:?})", + Angle::deg(c.hue.into_degrees().rem_euclid(360.0) as _), + Ratio::new(c.saturation as _), + Ratio::new(c.value as _), + Ratio::new(c.alpha as _), + ) + } } } } } -/// A color space for mixing. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum ColorSpace { - /// A perceptual color space. - Oklab, - /// The standard RGB color space. - Srgb, -} - -/// An 8-bit grayscale color. -#[derive(Copy, Clone, Eq, PartialEq, Hash)] -pub struct LumaColor(pub u8); - -impl LumaColor { - /// Construct a new luma color. - pub const fn new(luma: u8) -> Self { - Self(luma) - } - - /// Convert to an opque RGBA color. - pub const fn to_rgba(self) -> RgbaColor { - RgbaColor::new(self.0, self.0, self.0, u8::MAX) - } - - /// Convert to CMYK as a fraction of true black. - pub fn to_cmyk(self) -> CmykColor { - CmykColor::new( - round_u8(self.0 as f64 * 0.75), - round_u8(self.0 as f64 * 0.68), - round_u8(self.0 as f64 * 0.67), - round_u8(self.0 as f64 * 0.90), - ) - } - - /// Lighten this color by a factor. - pub fn lighten(self, factor: Ratio) -> Self { - let inc = round_u8((u8::MAX - self.0) as f64 * factor.get()); - Self(self.0.saturating_add(inc)) - } - - /// Darken this color by a factor. - pub fn darken(self, factor: Ratio) -> Self { - let dec = round_u8(self.0 as f64 * factor.get()); - Self(self.0.saturating_sub(dec)) - } - - /// Negate this color. - pub fn negate(self) -> Self { - Self(u8::MAX - self.0) - } -} - -impl Debug for LumaColor { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "luma({})", self.0) - } -} - -impl From for Color { - fn from(luma: LumaColor) -> Self { - Self::Luma(luma) - } -} - -/// An 8-bit RGBA color. -#[derive(Copy, Clone, Eq, PartialEq, Hash)] -pub struct RgbaColor { - /// Red channel. - pub r: u8, - /// Green channel. - pub g: u8, - /// Blue channel. - pub b: u8, - /// Alpha channel. - pub a: u8, -} - -impl RgbaColor { - /// Construct a new RGBA color. - pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { - Self { r, g, b, a } - } - - /// Lighten this color by a factor. - /// - /// The alpha channel is not affected. - pub fn lighten(self, factor: Ratio) -> Self { - let lighten = - |c: u8| c.saturating_add(round_u8((u8::MAX - c) as f64 * factor.get())); - Self { - r: lighten(self.r), - g: lighten(self.g), - b: lighten(self.b), - a: self.a, +impl PartialEq for Color { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + // Lower precision for comparison to avoid rounding errors. + // Keeps backward compatibility with previous versions of Typst. + (Self::Rgba(_), Self::Rgba(_)) => self.to_vec4_u8() == other.to_vec4_u8(), + (Self::Luma(a), Self::Luma(b)) => a == b, + (Self::Oklab(a), Self::Oklab(b)) => a == b, + (Self::LinearRgb(a), Self::LinearRgb(b)) => a == b, + (Self::Cmyk(a), Self::Cmyk(b)) => a == b, + (Self::Hsl(a), Self::Hsl(b)) => a == b, + (Self::Hsv(a), Self::Hsv(b)) => a == b, + _ => false, } } +} - /// Darken this color by a factor. - /// - /// The alpha channel is not affected. - pub fn darken(self, factor: Ratio) -> Self { - let darken = |c: u8| c.saturating_sub(round_u8(c as f64 * factor.get())); - Self { - r: darken(self.r), - g: darken(self.g), - b: darken(self.b), - a: self.a, - } - } +impl Eq for Color {} - /// Negate this color. - /// - /// The alpha channel is not affected. - pub fn negate(self) -> Self { - Self { - r: u8::MAX - self.r, - g: u8::MAX - self.g, - b: u8::MAX - self.b, - a: self.a, - } - } - - /// Converts this color to a RGB Hex Code. - pub fn to_hex(self) -> EcoString { - if self.a != 255 { - eco_format!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a) - } else { - eco_format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) - } - } - - /// Converts this color to an array of R, G, B, A components. - pub fn to_array(self) -> Array { - array![self.r, self.g, self.b, self.a] +impl Hash for Color { + fn hash(&self, state: &mut H) { + core::mem::discriminant(self).hash(state); + let [x, y, z, w] = self.to_vec4(); + x.to_bits().hash(state); + y.to_bits().hash(state); + z.to_bits().hash(state); + w.to_bits().hash(state); } } -impl FromStr for RgbaColor { +impl FromStr for Color { type Err = &'static str; /// Constructs a new color from hex strings like the following: @@ -551,161 +1174,216 @@ impl FromStr for RgbaColor { } } - Ok(Self::new(values[0], values[1], values[2], values[3])) + Ok(Self::from_u8(values[0], values[1], values[2], values[3])) } } -impl Debug for RgbaColor { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - if f.alternate() { - write!(f, "rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a,)?; - } else { - write!(f, "rgb(\"{}\")", self.to_hex())?; - } - Ok(()) - } -} - -impl> From for Color { - fn from(rgba: T) -> Self { - Self::Rgba(rgba.into()) - } -} - -cast! { - RgbaColor, - self => Value::Color(self.into()), -} - /// An 8-bit CMYK color. -#[derive(Copy, Clone, Eq, PartialEq, Hash)] -pub struct CmykColor { +#[derive(Copy, Clone, PartialEq)] +pub struct Cmyk { /// The cyan component. - pub c: u8, + pub c: f32, /// The magenta component. - pub m: u8, + pub m: f32, /// The yellow component. - pub y: u8, + pub y: f32, /// The key (black) component. - pub k: u8, + pub k: f32, } -impl CmykColor { - /// Construct a new CMYK color. - pub const fn new(c: u8, m: u8, y: u8, k: u8) -> Self { +impl Cmyk { + fn new(c: f32, m: f32, y: f32, k: f32) -> Self { Self { c, m, y, k } } - /// Convert this color to RGBA. - pub fn to_rgba(self) -> RgbaColor { - let k = self.k as f64 / 255.0; - let f = |c| { - let c = c as f64 / 255.0; - round_u8(255.0 * (1.0 - c) * (1.0 - k)) + fn from_luma(luma: Luma) -> Self { + let l = luma.luma; + Cmyk::new(l * 0.75, l * 0.68, l * 0.67, l * 0.90) + } + + fn from_rgba(rgba: Rgba) -> Self { + let r = rgba.red; + let g = rgba.green; + let b = rgba.blue; + + let k = 1.0 - r.max(g).max(b); + if k == 1.0 { + return Cmyk::new(0.0, 0.0, 0.0, 1.0); + } + + let c = (1.0 - r - k) / (1.0 - k); + let m = (1.0 - g - k) / (1.0 - k); + let y = (1.0 - b - k) / (1.0 - k); + + Cmyk::new(c, m, y, k) + } + + fn to_rgba(self) -> Rgba { + let r = (1.0 - self.c) * (1.0 - self.k); + let g = (1.0 - self.m) * (1.0 - self.k); + let b = (1.0 - self.y) * (1.0 - self.k); + + Rgba::new(r, g, b, 1.0) + } + + fn lighten(self, factor: f32) -> Self { + let lighten = |u: f32| (u - u * factor).clamp(0.0, 1.0); + Self::new(lighten(self.c), lighten(self.m), lighten(self.y), lighten(self.k)) + } + + fn darken(self, factor: f32) -> Self { + let darken = |u: f32| (u + (1.0 - u) * factor).clamp(0.0, 1.0); + Self::new(darken(self.c), darken(self.m), darken(self.y), darken(self.k)) + } +} + +/// A color with a weight. +pub struct WeightedColor { + color: Color, + weight: f64, +} + +impl WeightedColor { + /// Create a new weighted color. + pub const fn new(color: Color, weight: f64) -> Self { + Self { color, weight } + } +} + +cast! { + WeightedColor, + self => array![self.color, Value::Float(self.weight as _)].into_value(), + color: Color => Self { color, weight: 1.0 }, + v: Array => { + let mut iter = v.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(c), Some(w), None) => Self { + color: c.cast()?, + weight: w.cast::()?.0, + }, + _ => bail!("expected a color or color-weight pair"), + } + } +} + +/// A weight for color mixing. +struct Weight(f64); + +cast! { + Weight, + v: f64 => Self(v), + v: Ratio => Self(v.get()), +} + +/// A color space for mixing. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum ColorSpace { + /// A perceptual color space. + Oklab, + + /// The standard RGB color space. + Srgb, + + /// The D65-gray color space. + D65Gray, + + /// The linear RGB color space. + LinearRgb, + + /// The HSL color space. + Hsl, + + /// The HSV color space. + Hsv, + + /// The CMYK color space. + Cmyk, +} + +cast! { + ColorSpace, + self => match self { + Self::Oklab => Color::oklab_data(), + Self::Srgb => Color::rgb_data(), + Self::D65Gray => Color::luma_data(), + Self::LinearRgb => Color::linear_rgb_data(), + Self::Hsl => Color::hsl_data(), + Self::Hsv => Color::hsv_data(), + Self::Cmyk => Color::cmyk_data(), + }.into_value(), + v: Value => { + let expected = "expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`"; + let Value::Func(func) = v else { + bail!("{expected}, found {}", v.ty()); }; - RgbaColor { r: f(self.c), g: f(self.m), b: f(self.y), a: 255 } - } - - /// Lighten this color by a factor. - pub fn lighten(self, factor: Ratio) -> Self { - let lighten = |c: u8| c.saturating_sub(round_u8(c as f64 * factor.get())); - Self { - c: lighten(self.c), - m: lighten(self.m), - y: lighten(self.y), - k: lighten(self.k), + // Here comparing the function pointer since it's `Eq` + // whereas the `NativeFuncData` is not. + if func == Color::oklab_data() { + Self::Oklab + } else if func == Color::rgb_data() { + Self::Srgb + } else if func == Color::luma_data() { + Self::D65Gray + } else if func == Color::linear_rgb_data() { + Self::LinearRgb + } else if func == Color::hsl_data() { + Self::Hsl + } else if func == Color::hsv_data() { + Self::Hsv + } else if func == Color::cmyk_data() { + Self::Cmyk + } else { + bail!("{expected}"); } - } - - /// Darken this color by a factor. - pub fn darken(self, factor: Ratio) -> Self { - let darken = - |c: u8| c.saturating_add(round_u8((u8::MAX - c) as f64 * factor.get())); - Self { - c: darken(self.c), - m: darken(self.m), - y: darken(self.y), - k: darken(self.k), - } - } - - /// Negate this color. - /// - /// Does not affect the key component. - pub fn negate(self) -> Self { - Self { - c: u8::MAX - self.c, - m: u8::MAX - self.m, - y: u8::MAX - self.y, - k: self.k, - } - } - - /// Converts this color to an array of C, M, Y, K components. - pub fn to_array(self) -> Array { - // convert to ratio - let g = |c| Ratio::new(c as f64 / 255.0); - array![g(self.c), g(self.m), g(self.y), g(self.k)] - } -} - -impl Debug for CmykColor { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - let g = |c| 100.0 * (c as f64 / 255.0); - write!( - f, - "cmyk({:.1}%, {:.1}%, {:.1}%, {:.1}%)", - g(self.c), - g(self.m), - g(self.y), - g(self.k), - ) - } -} - -impl From for Color { - fn from(cmyk: CmykColor) -> Self { - Self::Cmyk(cmyk) - } -} - -cast! { - CmykColor, - self => Value::Color(self.into()), -} - -/// An integer or ratio component. -pub struct Component(u8); - -cast! { - Component, - v: i64 => match v { - 0 ..= 255 => Self(v as u8), - _ => bail!("number must be between 0 and 255"), - }, - v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { - Self((v.get() * 255.0).round() as u8) - } else { - bail!("ratio must be between 0% and 100%"); }, } /// A component that must be a ratio. -pub struct RatioComponent(u8); +pub struct RatioComponent(Ratio); cast! { RatioComponent, + self => self.0.into_value(), v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { - Self((v.get() * 255.0).round() as u8) + Self(v) } else { bail!("ratio must be between 0% and 100%"); }, } -/// Convert to the closest u8. -fn round_u8(value: f64) -> u8 { - value.round() as u8 +/// A component that must be a ratio between -40% and 40%. +pub struct ABComponent(Ratio); + +cast! { + ABComponent, + v: Ratio => if (-0.4 ..= 0.4).contains(&v.get()) { + Self(v) + } else { + bail!("ratio must be between -40% and 40%"); + }, + v: f64 => if (-0.4 ..= 0.4).contains(&v) { + Self(Ratio::new(v)) + } else { + bail!("ratio must be between -0.4 and 0.4"); + }, +} + +/// An integer or ratio component. +pub struct Component(Ratio); + +cast! { + Component, + self => self.0.into_value(), + v: i64 => match v { + 0 ..= 255 => Self(Ratio::new(v as f64 / 255.0)), + _ => bail!("number must be between 0 and 255"), + }, + v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { + Self(v) + } else { + bail!("ratio must be between 0% and 100%"); + }, } #[cfg(test)] @@ -716,13 +1394,13 @@ mod tests { fn test_parse_color_strings() { #[track_caller] fn test(hex: &str, r: u8, g: u8, b: u8, a: u8) { - assert_eq!(RgbaColor::from_str(hex), Ok(RgbaColor::new(r, g, b, a))); + assert_eq!(Color::from_str(hex), Ok(Color::from_u8(r, g, b, a))); } - test("f61243ff", 0xf6, 0x12, 0x43, 0xff); - test("b3d8b3", 0xb3, 0xd8, 0xb3, 0xff); + test("f61243ff", 0xf6, 0x12, 0x43, 255); + test("b3d8b3", 0xb3, 0xd8, 0xb3, 255); test("fCd2a9AD", 0xfc, 0xd2, 0xa9, 0xad); - test("233", 0x22, 0x33, 0x33, 0xff); + test("233", 0x22, 0x33, 0x33, 255); test("111b", 0x11, 0x11, 0x11, 0xbb); } @@ -730,7 +1408,7 @@ mod tests { fn test_parse_invalid_colors() { #[track_caller] fn test(hex: &str, message: &str) { - assert_eq!(RgbaColor::from_str(hex), Err(message)); + assert_eq!(Color::from_str(hex), Err(message)); } test("a5", "color string has wrong length"); diff --git a/crates/typst/src/geom/mod.rs b/crates/typst/src/geom/mod.rs index b6ccfb3a2..c5bcf84ea 100644 --- a/crates/typst/src/geom/mod.rs +++ b/crates/typst/src/geom/mod.rs @@ -31,9 +31,7 @@ pub use self::abs::{Abs, AbsUnit}; pub use self::align::{Align, FixedAlign, HAlign, VAlign}; pub use self::angle::{Angle, AngleUnit}; pub use self::axes::{Axes, Axis}; -pub use self::color::{ - CmykColor, Color, ColorSpace, LumaColor, RgbaColor, WeightedColor, -}; +pub use self::color::{Color, ColorSpace, WeightedColor}; pub use self::corners::{Corner, Corners}; pub use self::dir::Dir; pub use self::ellipse::ellipse; diff --git a/crates/typst/src/geom/scalar.rs b/crates/typst/src/geom/scalar.rs index 71fb1755f..71d300407 100644 --- a/crates/typst/src/geom/scalar.rs +++ b/crates/typst/src/geom/scalar.rs @@ -30,7 +30,7 @@ impl From for f64 { impl Debug for Scalar { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - self.0.fmt(f) + Debug::fmt(&self.0, f) } } diff --git a/tests/ref/compiler/color.png b/tests/ref/compiler/color.png index 8f329fab4..ff9514da1 100644 Binary files a/tests/ref/compiler/color.png and b/tests/ref/compiler/color.png differ diff --git a/tests/ref/compiler/repr.png b/tests/ref/compiler/repr.png index 994a6a922..82ece777c 100644 Binary files a/tests/ref/compiler/repr.png and b/tests/ref/compiler/repr.png differ diff --git a/tests/src/tests.rs b/tests/src/tests.rs index b575b4d48..e7595cf79 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -24,7 +24,7 @@ use typst::diag::{bail, FileError, FileResult, Severity, StrResult}; use typst::doc::{Document, Frame, FrameItem, Meta}; use typst::eval::{eco_format, func, Bytes, Datetime, Library, NoneValue, Tracer, Value}; use typst::font::{Font, FontBook}; -use typst::geom::{Abs, Color, RgbaColor, Smart}; +use typst::geom::{Abs, Color, Smart}; use typst::syntax::{FileId, PackageVersion, Source, Span, SyntaxNode, VirtualPath}; use typst::{World, WorldExt}; use typst_library::layout::{Margin, PageElem}; @@ -159,6 +159,14 @@ fn library() -> Library { Ok(NoneValue) } + #[func] + fn test_repr(lhs: Value, rhs: Value) -> StrResult { + if lhs.repr() != rhs.repr() { + bail!("Assertion failed: {lhs:?} != {rhs:?}"); + } + Ok(NoneValue) + } + #[func] fn print(#[variadic] values: Vec) -> NoneValue { let mut stdout = io::stdout().lock(); @@ -188,13 +196,14 @@ fn library() -> Library { // Hook up helpers into the global scope. lib.global.scope_mut().define_func::(); + lib.global.scope_mut().define_func::(); lib.global.scope_mut().define_func::(); lib.global .scope_mut() - .define("conifer", RgbaColor::new(0x9f, 0xEB, 0x52, 0xFF)); + .define("conifer", Color::from_u8(0x9f, 0xEB, 0x52, 0xFF)); lib.global .scope_mut() - .define("forest", RgbaColor::new(0x43, 0xA1, 0x27, 0xFF)); + .define("forest", Color::from_u8(0x43, 0xA1, 0x27, 0xFF)); lib } diff --git a/tests/typ/compiler/color.typ b/tests/typ/compiler/color.typ index fbb1749ba..71fce1b10 100644 --- a/tests/typ/compiler/color.typ +++ b/tests/typ/compiler/color.typ @@ -18,9 +18,59 @@ box(square(size: 9pt, fill: c.darken(x * 10%))) } +--- +// The the different color spaces +#let col = rgb(50%, 64%, 16%) +#box(square(size: 9pt, fill: col)) +#box(square(size: 9pt, fill: rgb(col))) +#box(square(size: 9pt, fill: oklab(col))) +#box(square(size: 9pt, fill: luma(col))) +#box(square(size: 9pt, fill: cmyk(col))) +#box(square(size: 9pt, fill: color.linear-rgb(col))) +#box(square(size: 9pt, fill: color.hsl(col))) +#box(square(size: 9pt, fill: color.hsv(col))) + +--- +// Test hue rotation +#let col = rgb(50%, 64%, 16%) + +#for x in range(0, 11) { + box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg))) +} + +#for x in range(0, 11) { + box(square(size: 9pt, fill: color.hsv(col).rotate(x * 36deg))) +} + +#for x in range(0, 11) { + box(square(size: 9pt, fill: color.hsl(col).rotate(x * 36deg))) +} + +--- +// Test saturation +#let col = color.hsl(180deg, 0%, 50%) +#for x in range(0, 11) { + box(square(size: 9pt, fill: col.saturate(x * 10%))) +} + +#let col = color.hsl(180deg, 100%, 50%) +#for x in range(0, 11) { + box(square(size: 9pt, fill: col.desaturate(x * 10%))) +} + +#let col = color.hsv(180deg, 0%, 50%) +#for x in range(0, 11) { + box(square(size: 9pt, fill: col.saturate(x * 10%))) +} + +#let col = color.hsv(180deg, 100%, 50%) +#for x in range(0, 11) { + box(square(size: 9pt, fill: col.desaturate(x * 10%))) +} + --- // Test gray color modification. // Ref: false -#test(luma(20%).lighten(50%), luma(60%)) -#test(luma(80%).darken(20%), luma(63.9%)) -#test(luma(80%).negate(), luma(20%)) +#test-repr(luma(20%).lighten(50%), luma(60%)) +#test-repr(luma(80%).darken(20%), luma(64%)) +#test-repr(luma(80%).negate(), luma(20%)) diff --git a/tests/typ/compiler/methods.typ b/tests/typ/compiler/methods.typ index e4c09ed25..8f70bd8c8 100644 --- a/tests/typ/compiler/methods.typ +++ b/tests/typ/compiler/methods.typ @@ -97,39 +97,40 @@ --- // Test color kind method. -#test(rgb(1, 2, 3, 4).kind(), rgb) -#test(cmyk(4%, 5%, 6%, 7%).kind(), cmyk) -#test(luma(40).kind(), luma) -#test(rgb(1, 2, 3, 4).kind() != luma, true) +#test(rgb(1, 2, 3, 4).space(), rgb) +#test(cmyk(4%, 5%, 6%, 7%).space(), cmyk) +#test(luma(40).space(), luma) +#test(rgb(1, 2, 3, 4).space() != luma, true) --- -// Test color '.rgba()', '.cmyk()' and '.luma()' without conversions -#test(rgb(1, 2, 3, 4).to-rgba(), (1, 2, 3, 4)) -#test(rgb(1, 2, 3).to-rgba(), (1, 2, 3, 255)) -#test(cmyk(20%, 20%, 40%, 20%).to-cmyk(), (20%, 20%, 40%, 20%)) -#test(luma(40).to-luma(), 40) +// Test color '.components()' without conversions +#test-repr(rgb(1, 2, 3, 4).components(), (0.39%, 0.78%, 1.18%, 1.57%)) +#test-repr(luma(40).components(), (15.69%, )) +#test-repr(cmyk(4%, 5%, 6%, 7%).components(), (4%, 5%, 6%, 7%)) +#test-repr(oklab(10%, 0.2, 0.3).components(), (10%, 0.2, 0.3, 100%)) +#test-repr(color.linear-rgb(10%, 20%, 30%).components(), (10%, 20%, 30%, 100%)) +#test-repr(color.hsv(10deg, 20%, 30%).components(), (10deg, 20%, 30%, 100%)) +#test-repr(color.hsl(10deg, 20%, 30%).components(), (10deg, 20%, 30%, 100%)) --- // Test color conversions. #test(rgb(1, 2, 3).to-hex(), "#010203") #test(rgb(1, 2, 3, 4).to-hex(), "#01020304") -#test(cmyk(4%, 5%, 6%, 7%).to-rgba(), (228, 225, 223, 255)) -#test(cmyk(4%, 5%, 6%, 7%).to-hex(), "#e4e1df") -#test(luma(40).to-rgba(), (40, 40, 40, 255)) #test(luma(40).to-hex(), "#282828") -#test(repr(luma(40).to-cmyk()), repr((11.76%, 10.59%, 10.59%, 14.12%))) - ---- -// Error: 2-27 cannot obtain cmyk values from rgba color -#rgb(1, 2, 3, 4).to-cmyk() - ---- -// Error: 2-27 cannot obtain the luma value of rgba color -#rgb(1, 2, 3, 4).to-luma() - ---- -// Error: 2-32 cannot obtain the luma value of cmyk color -#cmyk(4%, 5%, 6%, 7%).to-luma() +#test-repr(cmyk(4%, 5%, 6%, 7%).to-hex(), "#e4e1df") +#test-repr(rgb(cmyk(4%, 5%, 6%, 7%)).components(), (89.28%, 88.35%, 87.42%, 100%)) +#test-repr(rgb(luma(40%)).components(false), (40%, 40%, 40%)) +#test-repr(cmyk(luma(40)).components(), (11.76%, 10.67%, 10.51%, 14.12%)) +#test-repr(cmyk(rgb(1, 2, 3)), cmyk(66.67%, 33.33%, 0%, 98.82%)) +#test-repr(luma(rgb(1, 2, 3)), luma(0.73%)) +#test-repr(color.hsl(luma(40)), color.hsl(0deg, 0%, 15.69%)) +#test-repr(color.hsv(luma(40)), color.hsv(0deg, 0%, 15.69%)) +#test-repr(color.linear-rgb(luma(40)), color.linear-rgb(2.12%, 2.12%, 2.12%)) +#test-repr(color.linear-rgb(rgb(1, 2, 3)), color.linear-rgb(0.03%, 0.06%, 0.09%)) +#test-repr(color.hsl(rgb(1, 2, 3)), color.hsl(-150deg, 50%, 0.78%)) +#test-repr(color.hsv(rgb(1, 2, 3)), color.hsv(-150deg, 66.67%, 1.18%)) +#test-repr(oklab(luma(40)).components(), (27.68%, 0.0, 0.0, 100%)) +#test-repr(oklab(rgb(1, 2, 3)).components(), (8.23%, -0.004, -0.007, 100%)) --- // Test alignment methods. diff --git a/tests/typ/compiler/repr.typ b/tests/typ/compiler/repr.typ index 13593a868..ce5b29750 100644 --- a/tests/typ/compiler/repr.typ +++ b/tests/typ/compiler/repr.typ @@ -47,3 +47,13 @@ #int \ #type("hi") \ #type((a: 1)) + +--- +#set text(0.8em) +#blue \ +#color.linear-rgb(blue) \ +#oklab(blue) \ +#cmyk(blue) \ +#color.hsl(blue) \ +#color.hsv(blue) \ +#luma(blue) diff --git a/tests/typ/compute/construct.typ b/tests/typ/compute/construct.typ index bef86fae4..d3cea0b4c 100644 --- a/tests/typ/compute/construct.typ +++ b/tests/typ/compute/construct.typ @@ -3,7 +3,7 @@ --- // Compare both ways. -#test(rgb(0%, 30%, 70%), rgb("004db3")) +#test-repr(rgb(0%, 30.2%, 70.2%), rgb("004db3")) // Alpha channel. #test(rgb(255, 0, 0, 50%), rgb("ff000080")) @@ -15,24 +15,80 @@ #test(white.lighten(100%), white) // Color mixing, in Oklab space by default. -#test(color.mix(rgb("#ff0000"), rgb("#00ff00")), rgb("#d0a800")) -#test(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: "oklab"), rgb("#d0a800")) -#test(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: "srgb"), rgb("#808000")) +#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"))), rgb("#d0a800")) +#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: oklab)), rgb("#d0a800")) +#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: rgb)), rgb("#808000")) -#test(color.mix(red, green, blue), rgb("#909282")) -#test(color.mix(red, blue, green), rgb("#909282")) -#test(color.mix(blue, red, green), rgb("#909282")) +#test(rgb(color.mix(red, green, blue)), rgb("#909282")) +#test(rgb(color.mix(red, blue, green)), rgb("#909282")) +#test(rgb(color.mix(blue, red, green)), rgb("#909282")) // Mix with weights. -#test(color.mix((red, 50%), (green, 50%)), rgb("#c0983b")) -#test(color.mix((red, 0.5), (green, 0.5)), rgb("#c0983b")) -#test(color.mix((red, 5), (green, 5)), rgb("#c0983b")) -#test(color.mix((green, 5), (white, 0), (red, 5)), rgb("#c0983b")) -#test(color.mix((red, 100%), (green, 0%)), red) -#test(color.mix((red, 0%), (green, 100%)), green) -#test(color.mix((rgb("#aaff00"), 25%), (rgb("#aa00ff"), 75%), space: "srgb"), rgb("#aa40bf")) -#test(color.mix((rgb("#aaff00"), 50%), (rgb("#aa00ff"), 50%), space: "srgb"), rgb("#aa8080")) -#test(color.mix((rgb("#aaff00"), 75%), (rgb("#aa00ff"), 25%), space: "srgb"), rgb("#aabf40")) +#test(rgb(color.mix((red, 50%), (green, 50%))), rgb("#c0983b")) +#test(rgb(color.mix((red, 0.5), (green, 0.5))), rgb("#c0983b")) +#test(rgb(color.mix((red, 5), (green, 5))), rgb("#c0983b")) +#test(rgb(color.mix((green, 5), (white, 0), (red, 5))), rgb("#c0983b")) +#test(color.mix((rgb("#aaff00"), 25%), (rgb("#aa00ff"), 75%), space: rgb), rgb("#aa40bf")) +#test(color.mix((rgb("#aaff00"), 50%), (rgb("#aa00ff"), 50%), space: rgb), rgb("#aa8080")) +#test(color.mix((rgb("#aaff00"), 75%), (rgb("#aa00ff"), 25%), space: rgb), rgb("#aabf40")) + +--- +// Test color conversion method kinds +#test(rgb(rgb(10, 20, 30)).space(), rgb) +#test(color.linear-rgb(rgb(10, 20, 30)).space(), color.linear-rgb) +#test(oklab(rgb(10, 20, 30)).space(), oklab) +#test(color.hsl(rgb(10, 20, 30)).space(), color.hsl) +#test(color.hsv(rgb(10, 20, 30)).space(), color.hsv) +#test(cmyk(rgb(10, 20, 30)).space(), cmyk) +#test(luma(rgb(10, 20, 30)).space(), luma) + +#test(rgb(color.linear-rgb(10, 20, 30)).space(), rgb) +#test(color.linear-rgb(color.linear-rgb(10, 20, 30)).space(), color.linear-rgb) +#test(oklab(color.linear-rgb(10, 20, 30)).space(), oklab) +#test(color.hsl(color.linear-rgb(10, 20, 30)).space(), color.hsl) +#test(color.hsv(color.linear-rgb(10, 20, 30)).space(), color.hsv) +#test(cmyk(color.linear-rgb(10, 20, 30)).space(), cmyk) +#test(luma(color.linear-rgb(10, 20, 30)).space(), luma) + +#test(rgb(oklab(10%, 20%, 30%)).space(), rgb) +#test(color.linear-rgb(oklab(10%, 20%, 30%)).space(), color.linear-rgb) +#test(oklab(oklab(10%, 20%, 30%)).space(), oklab) +#test(color.hsl(oklab(10%, 20%, 30%)).space(), color.hsl) +#test(color.hsv(oklab(10%, 20%, 30%)).space(), color.hsv) +#test(cmyk(oklab(10%, 20%, 30%)).space(), cmyk) +#test(luma(oklab(10%, 20%, 30%)).space(), luma) + +#test(rgb(color.hsl(10deg, 20%, 30%)).space(), rgb) +#test(color.linear-rgb(color.hsl(10deg, 20%, 30%)).space(), color.linear-rgb) +#test(oklab(color.hsl(10deg, 20%, 30%)).space(), oklab) +#test(color.hsl(color.hsl(10deg, 20%, 30%)).space(), color.hsl) +#test(color.hsv(color.hsl(10deg, 20%, 30%)).space(), color.hsv) +#test(cmyk(color.hsl(10deg, 20%, 30%)).space(), cmyk) +#test(luma(color.hsl(10deg, 20%, 30%)).space(), luma) + +#test(rgb(color.hsv(10deg, 20%, 30%)).space(), rgb) +#test(color.linear-rgb(color.hsv(10deg, 20%, 30%)).space(), color.linear-rgb) +#test(oklab(color.hsv(10deg, 20%, 30%)).space(), oklab) +#test(color.hsl(color.hsv(10deg, 20%, 30%)).space(), color.hsl) +#test(color.hsv(color.hsv(10deg, 20%, 30%)).space(), color.hsv) +#test(cmyk(color.hsv(10deg, 20%, 30%)).space(), cmyk) +#test(luma(color.hsv(10deg, 20%, 30%)).space(), luma) + +#test(rgb(cmyk(10%, 20%, 30%, 40%)).space(), rgb) +#test(color.linear-rgb(cmyk(10%, 20%, 30%, 40%)).space(), color.linear-rgb) +#test(oklab(cmyk(10%, 20%, 30%, 40%)).space(), oklab) +#test(color.hsl(cmyk(10%, 20%, 30%, 40%)).space(), color.hsl) +#test(color.hsv(cmyk(10%, 20%, 30%, 40%)).space(), color.hsv) +#test(cmyk(cmyk(10%, 20%, 30%, 40%)).space(), cmyk) +#test(luma(cmyk(10%, 20%, 30%, 40%)).space(), luma) + +#test(rgb(luma(10%)).space(), rgb) +#test(color.linear-rgb(luma(10%)).space(), color.linear-rgb) +#test(oklab(luma(10%)).space(), oklab) +#test(color.hsl(luma(10%)).space(), color.hsl) +#test(color.hsv(luma(10%)).space(), color.hsv) +#test(cmyk(luma(10%)).space(), cmyk) +#test(luma(luma(10%)).space(), luma) --- // Test gray color conversion. @@ -70,9 +126,17 @@ #color.mix((red, 1, 2)) --- -// Error: 31-38 expected "oklab" or "srgb" +// Error: 31-38 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`, found string #color.mix(red, green, space: "cyber") +--- +// Error: 31-36 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv` +#color.mix(red, green, space: image) + +--- +// Error: 31-41 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv` +#color.mix(red, green, space: calc.round) + --- // Ref: true #let envelope = symbol(