diff --git a/CHANGELOG.md b/CHANGELOG.md
index e759488b1..7177fc799 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ This changelog also contains important changes in dependencies.
## [Unreleased]
### Fixed
+- Missing text when a `text` element uses multiple fonts and one of them produces ligatures.
- Absolute transform propagation during `use` resolving.
- Absolute transform propagation during nested `svg` resolving.
- `Node::abs_transform` documentation. The current element's transform _is_ included.
diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs
index 8ff4e4234..c294a30d5 100644
--- a/crates/resvg/tests/integration/render.rs
+++ b/crates/resvg/tests/integration/render.rs
@@ -650,6 +650,7 @@ use crate::render;
#[test] fn painting_context_in_nested_use_and_marker() { assert_eq!(render("tests/painting/context/in-nested-use-and-marker"), 0); }
#[test] fn painting_context_in_nested_use() { assert_eq!(render("tests/painting/context/in-nested-use"), 0); }
#[test] fn painting_context_in_use() { assert_eq!(render("tests/painting/context/in-use"), 0); }
+#[test] fn painting_context_on_shape_with_zero_size_bbox() { assert_eq!(render("tests/painting/context/on-shape-with-zero-size-bbox"), 0); }
#[test] fn painting_context_with_gradient_and_gradient_transform() { assert_eq!(render("tests/painting/context/with-gradient-and-gradient-transform"), 0); }
#[test] fn painting_context_with_gradient_in_use() { assert_eq!(render("tests/painting/context/with-gradient-in-use"), 0); }
#[test] fn painting_context_with_gradient_on_marker() { assert_eq!(render("tests/painting/context/with-gradient-on-marker"), 0); }
@@ -1478,6 +1479,8 @@ use crate::render;
#[test] fn text_text_escaped_text_4() { assert_eq!(render("tests/text/text/escaped-text-4"), 0); }
#[test] fn text_text_fill_rule_eq_evenodd() { assert_eq!(render("tests/text/text/fill-rule=evenodd"), 0); }
#[test] fn text_text_filter_bbox() { assert_eq!(render("tests/text/text/filter-bbox"), 0); }
+#[test] fn text_text_ligatures_handling_in_mixed_fonts_1() { assert_eq!(render("tests/text/text/ligatures-handling-in-mixed-fonts-1"), 0); }
+#[test] fn text_text_ligatures_handling_in_mixed_fonts_2() { assert_eq!(render("tests/text/text/ligatures-handling-in-mixed-fonts-2"), 0); }
#[test] fn text_text_mm_coordinates() { assert_eq!(render("tests/text/text/mm-coordinates"), 0); }
#[test] fn text_text_nested() { assert_eq!(render("tests/text/text/nested"), 0); }
#[test] fn text_text_no_coordinates() { assert_eq!(render("tests/text/text/no-coordinates"), 0); }
diff --git a/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.png b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.png
new file mode 100644
index 000000000..a5bd97ab9
Binary files /dev/null and b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.png differ
diff --git a/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.svg b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.svg
new file mode 100644
index 000000000..b0bcf3a87
--- /dev/null
+++ b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-1.svg
@@ -0,0 +1,19 @@
+
diff --git a/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.png b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.png
new file mode 100644
index 000000000..43a48444a
Binary files /dev/null and b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.png differ
diff --git a/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.svg b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.svg
new file mode 100644
index 000000000..1921c8495
--- /dev/null
+++ b/crates/resvg/tests/tests/text/text/ligatures-handling-in-mixed-fonts-2.svg
@@ -0,0 +1,19 @@
+
diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs
index d02449f97..64be0aade 100644
--- a/crates/usvg/src/text/layout.rs
+++ b/crates/usvg/src/text/layout.rs
@@ -752,8 +752,38 @@ fn process_chunk(
fonts_cache: &FontsCache,
fontdb: &fontdb::Database,
) -> Vec {
- let mut glyphs = Vec::new();
+ // The way this function works is a bit tricky.
+ //
+ // The first problem is BIDI reordering.
+ // We cannot shape text span-by-span, because glyph clusters are not guarantee to be continuous.
+ //
+ // For example:
+ // Hello שלום.
+ //
+ // Would be shaped as:
+ // H e l l o ש ל ו ם . (characters)
+ // 0 1 2 3 4 5 12 10 8 6 14 (cluster indices in UTF-8)
+ // --- --- (green span)
+ //
+ // As you can see, our continuous `lo של` span was split into two separated one.
+ // So our 3 spans: black - green - black, become 5 spans: black - green - black - green - black.
+ // If we shape `Hel`, then `lo של` an then `ום` separately - we would get an incorrect output.
+ // To properly handle this we simply shape the whole chunk.
+ //
+ // But this introduces another issue - what to do when we have multiple fonts?
+ // The easy solution would be to simply shape text with each font,
+ // where the first font output is used as a base one and all others overwrite it.
+ // This way in case of:
+ // Hello world
+ // we would replace Arial glyphs for `world` with Helvetica one. Pretty simple.
+ //
+ // Well, it would work most of the time, but not always.
+ // This is because different fonts can produce different amount of glyphs for the same text.
+ // The most common example are ligatures. Some fonts can shape `fi` as two glyphs `f` and `i`,
+ // but some can use `fi` (U+FB01) instead.
+ // Meaning that during merging we have to overwrite not individual glyphs, but clusters.
+ let mut glyphs = Vec::new();
for span in &chunk.spans {
let font = match fonts_cache.get(&span.font) {
Some(v) => v.clone(),
@@ -774,18 +804,35 @@ fn process_chunk(
continue;
}
- // We assume, that shaping with an any font will produce the same amount of glyphs.
- // Otherwise an error.
- if glyphs.len() != tmp_glyphs.len() {
- log::warn!("Text layouting failed.");
- return Vec::new();
- }
+ // Overwrite span's glyphs.
+ let mut iter = tmp_glyphs.into_iter();
+ while let Some(new_glyph) = iter.next() {
+ if !span_contains(span, new_glyph.byte_idx) {
+ continue;
+ }
+
+ let Some(idx) = glyphs.iter().position(|g| g.byte_idx == new_glyph.byte_idx) else {
+ continue;
+ };
- // Copy span's glyphs.
- for (i, glyph) in tmp_glyphs.iter().enumerate() {
- if span_contains(span, glyph.byte_idx) {
- glyphs[i] = glyph.clone();
+ let prev_cluster_len = glyphs[idx].cluster_len;
+ if prev_cluster_len < new_glyph.cluster_len {
+ // If the new font represents the same cluster with fewer glyphs
+ // then remove remaining glyphs.
+ for _ in 1..new_glyph.cluster_len {
+ glyphs.remove(idx + 1);
+ }
+ } else if prev_cluster_len > new_glyph.cluster_len {
+ // If the new font represents the same cluster with more glyphs
+ // then insert them after the current one.
+ for j in 1..prev_cluster_len {
+ if let Some(g) = iter.next() {
+ glyphs.insert(idx + j, g);
+ }
+ }
}
+
+ glyphs[idx] = new_glyph;
}
}
@@ -1326,6 +1373,7 @@ fn shape_text_with_font(
glyphs.push(Glyph {
byte_idx: ByteIndex::new(idx),
+ cluster_len: end.checked_sub(start).unwrap_or(0), // TODO: can fail?
text: sub_text[start..end].to_string(),
id: GlyphId(info.glyph_id as u16),
dx: pos.x_offset,
@@ -1468,6 +1516,9 @@ pub(crate) struct Glyph {
/// We use it to match a glyph with a character in the text chunk and therefore with the style.
pub(crate) byte_idx: ByteIndex,
+ // The length of the cluster in bytes.
+ pub(crate) cluster_len: usize,
+
/// The text from the original string that corresponds to that glyph.
pub(crate) text: String,