From 1110b935646fb4174cfce020613378ca7a7e8300 Mon Sep 17 00:00:00 2001 From: Laurenz Stampfl <47084093+LaurenzV@users.noreply.github.com> Date: Sun, 16 Jun 2024 09:47:13 +0200 Subject: [PATCH] Add support for COLRv1 emojis (#4371) --- Cargo.lock | 3 +- Cargo.toml | 2 +- crates/typst/Cargo.toml | 1 + crates/typst/src/text/font/color.rs | 493 +++++++++++++++++++++++----- tests/ref/issue-3733-dpi-svg.png | Bin 0 -> 124 bytes tests/ref/shaping-emoji-basic.png | Bin 948 -> 800 bytes tests/ref/shaping-font-fallback.png | Bin 3702 -> 3488 bytes tests/suite/visualize/image.typ | 4 + 8 files changed, 423 insertions(+), 80 deletions(-) create mode 100644 tests/ref/issue-3733-dpi-svg.png diff --git a/Cargo.lock b/Cargo.lock index 99295d90..72f757cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2600,6 +2600,7 @@ dependencies = [ "unscanny", "usvg", "wasmi", + "xmlwriter", ] [[package]] @@ -2657,7 +2658,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.11.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=ee8ae61cca138dc92f9d818fc7f2fc046d0148c5#ee8ae61cca138dc92f9d818fc7f2fc046d0148c5" +source = "git+https://github.com/typst/typst-dev-assets?rev=48a924d9de82b631bc775124a69384c8d860db04#48a924d9de82b631bc775124a69384c8d860db04" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index 40051a5a..367e835a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.11.0" } typst-timing = { path = "crates/typst-timing", version = "0.11.0" } typst-utils = { path = "crates/typst-utils", version = "0.11.0" } typst-assets = "0.11.0" -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "ee8ae61cca138dc92f9d818fc7f2fc046d0148c5" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "48a924d9de82b631bc775124a69384c8d860db04" } az = "1.2" base64 = "0.22" bitflags = { version = "2", features = ["serde"] } diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 3934ff42..b9078037 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -68,6 +68,7 @@ unicode-script = { workspace = true } unicode-segmentation = { workspace = true } unscanny = { workspace = true } usvg = { workspace = true } +xmlwriter = { workspace = true } wasmi = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/typst/src/text/font/color.rs b/crates/typst/src/text/font/color.rs index ceddeeae..239fbdee 100644 --- a/crates/typst/src/text/font/color.rs +++ b/crates/typst/src/text/font/color.rs @@ -2,13 +2,14 @@ use std::io::Read; -use ecow::EcoString; -use ttf_parser::GlyphId; +use ttf_parser::{GlyphId, RgbaColor}; +use usvg::tiny_skia_path; +use xmlwriter::XmlWriter; -use crate::layout::{Abs, Axes, Em, Frame, FrameItem, Point, Size}; +use crate::layout::{Abs, Axes, Frame, FrameItem, Point, Size}; use crate::syntax::Span; -use crate::text::{Font, Glyph, Lang, TextItem}; -use crate::visualize::{Color, Image, Rgb}; +use crate::text::{Font, Glyph}; +use crate::visualize::Image; /// Tells if a glyph is a color glyph or not in a given font. pub fn is_color_glyph(font: &Font, g: &Glyph) -> bool { @@ -33,15 +34,81 @@ pub fn frame_for_glyph(font: &Font, glyph_id: u16) -> Frame { if let Some(raster_image) = ttf.glyph_raster_image(glyph_id, u16::MAX) { draw_raster_glyph(&mut frame, font, upem, raster_image); + } else if ttf.is_color_glyph(glyph_id) { + draw_colr_glyph(&mut frame, upem, ttf, glyph_id); } else if ttf.glyph_svg_image(glyph_id).is_some() { draw_svg_glyph(&mut frame, upem, font, glyph_id); - } else if ttf.is_color_glyph(glyph_id) { - draw_colr_glyph(&mut frame, font, glyph_id); } frame } +fn draw_colr_glyph( + frame: &mut Frame, + upem: Abs, + ttf: &ttf_parser::Face, + glyph_id: GlyphId, +) -> Option<()> { + let mut svg = XmlWriter::new(xmlwriter::Options::default()); + + let width = ttf.global_bounding_box().width() as f64; + let height = ttf.global_bounding_box().height() as f64; + let x_min = ttf.global_bounding_box().x_min as f64; + let y_max = ttf.global_bounding_box().y_max as f64; + let tx = -x_min; + let ty = -y_max; + + svg.start_element("svg"); + svg.write_attribute("xmlns", "http://www.w3.org/2000/svg"); + svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + svg.write_attribute("width", &width); + svg.write_attribute("height", &height); + svg.write_attribute_fmt("viewBox", format_args!("0 0 {width} {height}")); + + let mut path_buf = String::with_capacity(256); + let gradient_index = 1; + let clip_path_index = 1; + + svg.start_element("g"); + svg.write_attribute_fmt( + "transform", + format_args!("matrix(1 0 0 -1 0 0) matrix(1 0 0 1 {tx} {ty})"), + ); + + let mut glyph_painter = GlyphPainter { + face: ttf, + svg: &mut svg, + path_buf: &mut path_buf, + gradient_index, + clip_path_index, + palette_index: 0, + transform: ttf_parser::Transform::default(), + outline_transform: ttf_parser::Transform::default(), + transforms_stack: vec![ttf_parser::Transform::default()], + }; + + ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter) + .unwrap(); + svg.end_element(); + + let data = svg.end_document().into_bytes(); + + let image = Image::new( + data.into(), + typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg), + None, + ) + .unwrap(); + + let y_shift = Abs::raw(upem.to_raw() - y_max); + + let position = Point::new(Abs::raw(x_min), y_shift); + let size = Axes::new(Abs::pt(width), Abs::pt(height)); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + /// Draws a raster glyph in a frame. fn draw_raster_glyph( frame: &mut Frame, @@ -74,77 +141,6 @@ fn draw_raster_glyph( frame.push(position, FrameItem::Image(image, size, Span::detached())); } -/// Draws a COLR glyph in a frame. -fn draw_colr_glyph(frame: &mut Frame, font: &Font, glyph_id: GlyphId) { - let mut painter = ColrPainter { font, current_glyph: glyph_id, frame }; - let black = ttf_parser::RgbaColor::new(0, 0, 0, 255); - font.ttf().paint_color_glyph(glyph_id, 0, black, &mut painter); -} - -/// Draws COLR glyphs in a frame. -struct ColrPainter<'f, 't> { - /// The frame in which to draw. - frame: &'f mut Frame, - /// The font of the text. - font: &'t Font, - /// The glyph that will be drawn the next time `ColrPainter::paint` is called. - current_glyph: GlyphId, -} - -impl<'f, 't> ttf_parser::colr::Painter<'_> for ColrPainter<'f, 't> { - fn outline_glyph(&mut self, glyph_id: GlyphId) { - self.current_glyph = glyph_id; - } - - fn paint(&mut self, paint: ttf_parser::colr::Paint) { - let ttf_parser::colr::Paint::Solid(color) = paint else { return }; - let color = Color::Rgb(Rgb::new( - color.red as f32 / 255.0, - color.green as f32 / 255.0, - color.blue as f32 / 255.0, - color.alpha as f32 / 255.0, - )); - - self.frame.push( - // With images, the position corresponds to the top-left corner, but - // in the case of text it matches the baseline-left point. Here, we - // move the glyph one unit down to compensate for that. - Point::new(Abs::zero(), Abs::pt(self.font.units_per_em())), - FrameItem::Text(TextItem { - font: self.font.clone(), - size: Abs::pt(self.font.units_per_em()), - fill: color.into(), - stroke: None, - lang: Lang::ENGLISH, - region: None, - text: EcoString::new(), - glyphs: vec![Glyph { - id: self.current_glyph.0, - // Advance is not relevant here as we will draw glyph on top - // of each other anyway - x_advance: Em::zero(), - x_offset: Em::zero(), - range: 0..0, - span: (Span::detached(), 0), - }], - }), - ); - } - - // These are not implemented. - fn push_clip(&mut self) {} - fn push_clip_box(&mut self, _: ttf_parser::colr::ClipBox) {} - fn pop_clip(&mut self) {} - fn push_layer(&mut self, _: ttf_parser::colr::CompositeMode) {} - fn pop_layer(&mut self) {} - fn push_translate(&mut self, _: f32, _: f32) {} - fn push_scale(&mut self, _: f32, _: f32) {} - fn push_rotate(&mut self, _: f32) {} - fn push_skew(&mut self, _: f32, _: f32) {} - fn push_transform(&mut self, _: ttf_parser::Transform) {} - fn pop_transform(&mut self) {} -} - /// Draws an SVG glyph in a frame. fn draw_svg_glyph( frame: &mut Frame, @@ -152,6 +148,8 @@ fn draw_svg_glyph( font: &Font, glyph_id: GlyphId, ) -> Option<()> { + // TODO: Our current conversion of the SVG table works for Twitter Color Emoji, + // but might not work for others. See also: https://github.com/RazrFalcon/resvg/pull/776 let mut data = font.ttf().glyph_svg_image(glyph_id)?.data; // Decompress SVGZ. @@ -266,3 +264,342 @@ fn make_svg_unsized(svg: &mut String) { svg.replace_range(range, ""); } } + +struct ColrBuilder<'a>(&'a mut String); + +impl ColrBuilder<'_> { + fn finish(&mut self) { + if !self.0.is_empty() { + self.0.pop(); // remove trailing space + } + } +} + +impl ttf_parser::OutlineBuilder for ColrBuilder<'_> { + fn move_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "M {x} {y} ").unwrap() + } + + fn line_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "L {x} {y} ").unwrap() + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "Q {x1} {y1} {x} {y} ").unwrap() + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "C {x1} {y1} {x2} {y2} {x} {y} ").unwrap() + } + + fn close(&mut self) { + self.0.push_str("Z ") + } +} + +// NOTE: This is only a best-effort translation of COLR into SVG. It's not feature-complete +// and it's also not possible to make it feature-complete using just raw SVG features. +pub(crate) struct GlyphPainter<'a> { + pub(crate) face: &'a ttf_parser::Face<'a>, + pub(crate) svg: &'a mut xmlwriter::XmlWriter, + pub(crate) path_buf: &'a mut String, + pub(crate) gradient_index: usize, + pub(crate) clip_path_index: usize, + pub(crate) palette_index: u16, + pub(crate) transform: ttf_parser::Transform, + pub(crate) outline_transform: ttf_parser::Transform, + pub(crate) transforms_stack: Vec, +} + +impl<'a> GlyphPainter<'a> { + fn write_gradient_stops(&mut self, stops: ttf_parser::colr::GradientStopsIter) { + for stop in stops { + self.svg.start_element("stop"); + self.svg.write_attribute("offset", &stop.stop_offset); + self.write_color_attribute("stop-color", stop.color); + let opacity = f32::from(stop.color.alpha) / 255.0; + self.svg.write_attribute("stop-opacity", &opacity); + self.svg.end_element(); + } + } + + fn write_color_attribute(&mut self, name: &str, color: ttf_parser::RgbaColor) { + self.svg.write_attribute_fmt( + name, + format_args!("rgb({}, {}, {})", color.red, color.green, color.blue), + ); + } + + fn write_transform_attribute(&mut self, name: &str, ts: ttf_parser::Transform) { + if ts.is_default() { + return; + } + + self.svg.write_attribute_fmt( + name, + format_args!("matrix({} {} {} {} {} {})", ts.a, ts.b, ts.c, ts.d, ts.e, ts.f), + ); + } + + fn write_spread_method_attribute( + &mut self, + extend: ttf_parser::colr::GradientExtend, + ) { + self.svg.write_attribute( + "spreadMethod", + match extend { + ttf_parser::colr::GradientExtend::Pad => &"pad", + ttf_parser::colr::GradientExtend::Repeat => &"repeat", + ttf_parser::colr::GradientExtend::Reflect => &"reflect", + }, + ); + } + + fn paint_solid(&mut self, color: ttf_parser::RgbaColor) { + self.svg.start_element("path"); + self.write_color_attribute("fill", color); + let opacity = f32::from(color.alpha) / 255.0; + self.svg.write_attribute("fill-opacity", &opacity); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_linear_gradient(&mut self, gradient: ttf_parser::colr::LinearGradient<'a>) { + let gradient_id = format!("lg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + // TODO: We ignore x2, y2. Have to apply them somehow. + // TODO: The way spreadMode works in ttf and svg is a bit different. In SVG, the spreadMode + // will always be applied based on x1/y1 and x2/y2. However, in TTF the spreadMode will + // be applied from the first/last stop. So if we have a gradient with x1=0 x2=1, and + // a stop at x=0.4 and x=0.6, then in SVG we will always see a padding, while in ttf + // we will see the actual spreadMode. We need to account for that somehow. + self.svg.start_element("linearGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("x1", &gradient.x0); + self.svg.write_attribute("y1", &gradient.y0); + self.svg.write_attribute("x2", &gradient.x1); + self.svg.write_attribute("y2", &gradient.y1); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_radial_gradient(&mut self, gradient: ttf_parser::colr::RadialGradient<'a>) { + let gradient_id = format!("rg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + self.svg.start_element("radialGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("cx", &gradient.x1); + self.svg.write_attribute("cy", &gradient.y1); + self.svg.write_attribute("r", &gradient.r1); + self.svg.write_attribute("fr", &gradient.r0); + self.svg.write_attribute("fx", &gradient.x0); + self.svg.write_attribute("fy", &gradient.y0); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_sweep_gradient(&mut self, _: ttf_parser::colr::SweepGradient<'a>) {} +} + +fn paint_transform( + outline_transform: ttf_parser::Transform, + transform: ttf_parser::Transform, +) -> ttf_parser::Transform { + let outline_transform = tiny_skia_path::Transform::from_row( + outline_transform.a, + outline_transform.b, + outline_transform.c, + outline_transform.d, + outline_transform.e, + outline_transform.f, + ); + + let gradient_transform = tiny_skia_path::Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ); + + let gradient_transform = outline_transform + .invert() + // In theory, we should error out. But the transform shouldn't ever be uninvertible, so let's ignore it. + .unwrap_or_default() + .pre_concat(gradient_transform); + + ttf_parser::Transform { + a: gradient_transform.sx, + b: gradient_transform.ky, + c: gradient_transform.kx, + d: gradient_transform.sy, + e: gradient_transform.tx, + f: gradient_transform.ty, + } +} + +impl GlyphPainter<'_> { + fn clip_with_path(&mut self, path: &str) { + let clip_id = format!("cp{}", self.clip_path_index); + self.clip_path_index += 1; + + self.svg.start_element("clipPath"); + self.svg.write_attribute("id", &clip_id); + self.svg.start_element("path"); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", &path); + self.svg.end_element(); + self.svg.end_element(); + + self.svg.start_element("g"); + self.svg + .write_attribute_fmt("clip-path", format_args!("url(#{clip_id})")); + } +} + +impl<'a> ttf_parser::colr::Painter<'a> for GlyphPainter<'a> { + fn outline_glyph(&mut self, glyph_id: ttf_parser::GlyphId) { + self.path_buf.clear(); + let mut builder = ColrBuilder(self.path_buf); + match self.face.outline_glyph(glyph_id, &mut builder) { + Some(v) => v, + None => return, + }; + builder.finish(); + + // We have to write outline using the current transform. + self.outline_transform = self.transform; + } + + fn push_layer(&mut self, mode: ttf_parser::colr::CompositeMode) { + self.svg.start_element("g"); + + use ttf_parser::colr::CompositeMode; + // TODO: Need to figure out how to represent the other blend modes + // in SVG. + let mode = match mode { + CompositeMode::SourceOver => "normal", + CompositeMode::Screen => "screen", + CompositeMode::Overlay => "overlay", + CompositeMode::Darken => "darken", + CompositeMode::Lighten => "lighten", + CompositeMode::ColorDodge => "color-dodge", + CompositeMode::ColorBurn => "color-burn", + CompositeMode::HardLight => "hard-light", + CompositeMode::SoftLight => "soft-light", + CompositeMode::Difference => "difference", + CompositeMode::Exclusion => "exclusion", + CompositeMode::Multiply => "multiply", + CompositeMode::Hue => "hue", + CompositeMode::Saturation => "saturation", + CompositeMode::Color => "color", + CompositeMode::Luminosity => "luminosity", + _ => "normal", + }; + self.svg.write_attribute_fmt( + "style", + format_args!("mix-blend-mode: {mode}; isolation: isolate"), + ); + } + + fn pop_layer(&mut self) { + self.svg.end_element(); // g + } + + fn push_translate(&mut self, tx: f32, ty: f32) { + self.push_transform(ttf_parser::Transform::new(1.0, 0.0, 0.0, 1.0, tx, ty)); + } + + fn push_scale(&mut self, sx: f32, sy: f32) { + self.push_transform(ttf_parser::Transform::new(sx, 0.0, 0.0, sy, 0.0, 0.0)); + } + + fn push_rotate(&mut self, angle: f32) { + let cc = (angle * std::f32::consts::PI).cos(); + let ss = (angle * std::f32::consts::PI).sin(); + self.push_transform(ttf_parser::Transform::new(cc, ss, -ss, cc, 0.0, 0.0)); + } + + fn push_skew(&mut self, skew_x: f32, skew_y: f32) { + let x = (-skew_x * std::f32::consts::PI).tan(); + let y = (skew_y * std::f32::consts::PI).tan(); + self.push_transform(ttf_parser::Transform::new(1.0, y, x, 1.0, 0.0, 0.0)); + } + + fn push_transform(&mut self, transform: ttf_parser::Transform) { + self.transforms_stack.push(self.transform); + self.transform = ttf_parser::Transform::combine(self.transform, transform); + } + + fn paint(&mut self, paint: ttf_parser::colr::Paint<'a>) { + match paint { + ttf_parser::colr::Paint::Solid(color) => self.paint_solid(color), + ttf_parser::colr::Paint::LinearGradient(lg) => self.paint_linear_gradient(lg), + ttf_parser::colr::Paint::RadialGradient(rg) => self.paint_radial_gradient(rg), + ttf_parser::colr::Paint::SweepGradient(sg) => self.paint_sweep_gradient(sg), + } + } + + fn pop_transform(&mut self) { + if let Some(ts) = self.transforms_stack.pop() { + self.transform = ts + } + } + + fn push_clip(&mut self) { + self.clip_with_path(&self.path_buf.clone()); + } + + fn pop_clip(&mut self) { + self.svg.end_element(); + } + + fn push_clip_box(&mut self, clipbox: ttf_parser::colr::ClipBox) { + let x_min = clipbox.x_min; + let x_max = clipbox.x_max; + let y_min = clipbox.y_min; + let y_max = clipbox.y_max; + + let clip_path = format!( + "M {x_min} {y_min} L {x_max} {y_min} L {x_max} {y_max} L {x_min} {y_max} Z" + ); + + self.clip_with_path(&clip_path); + } +} diff --git a/tests/ref/issue-3733-dpi-svg.png b/tests/ref/issue-3733-dpi-svg.png new file mode 100644 index 0000000000000000000000000000000000000000..15b0cc52c5944587e325a4717b657f1e828ca48a GIT binary patch literal 124 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5y8Awi_W^)%vu?6^qxH2>_{AXZTC26|>C}Qa8 z;uunK>+KCgUIqq^0|s**-WTSSc<@O10{{R3_&{+30004lP)t-s|NsB~{{Q~|{{8;`{Qdp?{QUd; z{Q3L*`TP6#_4D`i^Z)SK?(_8R^7QNR^6c>P>+tjM>gVj|<=p1!+vVy1+o%84kKotS z|JrK*++F|JZ{O9>|Iv-!)X)FXh5OW8{nAta$8i6~bM??!@sX|wf4tAs`qCi$$w~jg zXYR>g{KZPQ%+~e7O#8q^|Gq>2x>W1LRQS1O^}kBZzP7N&&HuJdt;Ej%wL9y$TJ^U> zsKU6u~iF$07r;vKn|6MeZa?zZAZkTJnUyYrTXv&gi z%vy<=m2_kOKuS}EnnHGmSz@S?0wI5{k4TaL00CG@L_t(|+U?a@I{w zQEZe&#XvEzyT$;!YcbHH=%63n)d%MzoMC;=?_%cifA<~}A%qxNMIg|YXQ~?xG$4n= z;eoKH^5;bkclWrx^`0TGy9qh0j;gBDsp=ncxLQ%VmR33xr4~8t^Up;h(~*A(zYjSa zJKSn@)wtI8H0*FJ)_Anteyd3c?+(uFXcEHL8$O)fAiIlDlz;3tOu(O%AeSlZa z6TQe`2k73CoS#Vve{y~*Bb;{l0GN^yhA+;mki)r-8vsc1_flF?LX65>ESOQlS^Ywe z1v6Z}II_xq-S-uuhG&^>ld+RHa(Iog&Y~b=7uaFO*nCh@m{ItDhwtKln)5Nk)-oX* kb-64Bf;EFq7a_*+4Z0?JC0ANHF#rGn07*qoM6N<$f-yg^U;qFB delta 929 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