mirror of
https://github.com/servo/servo.git
synced 2025-08-03 20:50:07 +01:00
layout: Linebreak the entire InlineFormattingContext at once (#32483)
Instead of linebreaking inside each single-font text segment, linebreak the entire inline formatting context at once. This has several benefits: 1. It allows us to use `icu_segmenter` (already in use from style), which is written against a newer version of the Unicode spec -- preventing breaking emoji clusters. 2. Opens up the possibility of changing the way that linebreaking and shaping work -- eventually allowing shaping across inline box boundaries and line breaking *after* shaping. Co-authored-by: Rakhi Sharma <atbrakhi@igalia.com>
This commit is contained in:
parent
43a7dd5da0
commit
bae9f6d844
50 changed files with 356 additions and 220 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3338,6 +3338,7 @@ dependencies = [
|
||||||
"gfx",
|
"gfx",
|
||||||
"gfx_traits",
|
"gfx_traits",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
|
"icu_segmenter",
|
||||||
"ipc-channel",
|
"ipc-channel",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"log",
|
"log",
|
||||||
|
|
|
@ -55,6 +55,7 @@ http = "0.2"
|
||||||
hyper = "0.14"
|
hyper = "0.14"
|
||||||
hyper-rustls = { version = "0.24", default-features = false, features = ["acceptor", "http1", "http2", "logging", "tls12", "webpki-tokio"] }
|
hyper-rustls = { version = "0.24", default-features = false, features = ["acceptor", "http1", "http2", "logging", "tls12", "webpki-tokio"] }
|
||||||
hyper_serde = { path = "components/hyper_serde" }
|
hyper_serde = { path = "components/hyper_serde" }
|
||||||
|
icu_segmenter = "1.5.0"
|
||||||
image = "0.24"
|
image = "0.24"
|
||||||
imsz = "0.2"
|
imsz = "0.2"
|
||||||
indexmap = { version = "2.2.6", features = ["std"] }
|
indexmap = { version = "2.2.6", features = ["std"] }
|
||||||
|
|
|
@ -9,7 +9,7 @@ publish = false
|
||||||
[lib]
|
[lib]
|
||||||
name = "layout_2020"
|
name = "layout_2020"
|
||||||
path = "lib.rs"
|
path = "lib.rs"
|
||||||
test = false
|
test = true
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
@ -26,6 +26,7 @@ fxhash = { workspace = true }
|
||||||
gfx = { path = "../gfx" }
|
gfx = { path = "../gfx" }
|
||||||
gfx_traits = { workspace = true }
|
gfx_traits = { workspace = true }
|
||||||
html5ever = { workspace = true }
|
html5ever = { workspace = true }
|
||||||
|
icu_segmenter = { workspace = true }
|
||||||
ipc-channel = { workspace = true }
|
ipc-channel = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
net_traits = { workspace = true }
|
net_traits = { workspace = true }
|
||||||
|
|
|
@ -131,8 +131,16 @@ impl InlineFormattingContextBuilder {
|
||||||
ArcRefCell::new(InlineLevelBox::Atomic(independent_formatting_context));
|
ArcRefCell::new(InlineLevelBox::Atomic(independent_formatting_context));
|
||||||
self.current_inline_level_boxes()
|
self.current_inline_level_boxes()
|
||||||
.push(inline_level_box.clone());
|
.push(inline_level_box.clone());
|
||||||
|
|
||||||
|
// Push an object replacement character for this atomic, which will ensure that the line breaker
|
||||||
|
// inserts a line breaking opportunity here.
|
||||||
|
let string_to_push = "\u{fffc}";
|
||||||
|
self.text_segments.push(string_to_push.to_owned());
|
||||||
|
self.current_text_offset += string_to_push.len();
|
||||||
|
|
||||||
self.last_inline_box_ended_with_collapsible_white_space = false;
|
self.last_inline_box_ended_with_collapsible_white_space = false;
|
||||||
self.on_word_boundary = true;
|
self.on_word_boundary = true;
|
||||||
|
|
||||||
inline_level_box
|
inline_level_box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
120
components/layout_2020/flow/inline/line_breaker.rs
Normal file
120
components/layout_2020/flow/inline/line_breaker.rs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use icu_segmenter::LineSegmenter;
|
||||||
|
|
||||||
|
pub(crate) struct LineBreaker {
|
||||||
|
linebreaks: Vec<usize>,
|
||||||
|
current_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LineBreaker {
|
||||||
|
pub(crate) fn new(string: &str) -> Self {
|
||||||
|
let line_segmenter = LineSegmenter::new_auto();
|
||||||
|
Self {
|
||||||
|
// From https://docs.rs/icu_segmenter/1.5.0/icu_segmenter/struct.LineSegmenter.html
|
||||||
|
// > For consistency with the grapheme, word, and sentence segmenters, there is always a
|
||||||
|
// > breakpoint returned at index 0, but this breakpoint is not a meaningful line break
|
||||||
|
// > opportunity.
|
||||||
|
//
|
||||||
|
// Skip this first line break opportunity, as it isn't interesting to us.
|
||||||
|
linebreaks: line_segmenter.segment_str(string).skip(1).collect(),
|
||||||
|
current_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn advance_to_linebreaks_in_range(&mut self, text_range: Range<usize>) -> &[usize] {
|
||||||
|
let linebreaks_in_range = self.linebreaks_in_range_after_current_offset(text_range);
|
||||||
|
self.current_offset = linebreaks_in_range.end;
|
||||||
|
&self.linebreaks[linebreaks_in_range]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linebreaks_in_range_after_current_offset(&self, text_range: Range<usize>) -> Range<usize> {
|
||||||
|
assert!(text_range.start <= text_range.end);
|
||||||
|
|
||||||
|
let mut linebreaks_range = self.current_offset..self.linebreaks.len();
|
||||||
|
|
||||||
|
while self.linebreaks[linebreaks_range.start] < text_range.start &&
|
||||||
|
linebreaks_range.len() > 1
|
||||||
|
{
|
||||||
|
linebreaks_range.start += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ending_linebreak_index = linebreaks_range.start;
|
||||||
|
while self.linebreaks[ending_linebreak_index] < text_range.end &&
|
||||||
|
ending_linebreak_index < self.linebreaks.len() - 1
|
||||||
|
{
|
||||||
|
ending_linebreak_index += 1;
|
||||||
|
}
|
||||||
|
linebreaks_range.end = ending_linebreak_index;
|
||||||
|
linebreaks_range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_linebreaker_ranges() {
|
||||||
|
let linebreaker = LineBreaker::new("abc def");
|
||||||
|
assert_eq!(linebreaker.linebreaks, [4, 7]);
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(0..5),
|
||||||
|
0..1
|
||||||
|
);
|
||||||
|
// The last linebreak should not be included for the text range we are interested in.
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(0..7),
|
||||||
|
0..1
|
||||||
|
);
|
||||||
|
|
||||||
|
let linebreaker = LineBreaker::new("abc d def");
|
||||||
|
assert_eq!(linebreaker.linebreaks, [4, 6, 9]);
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(0..5),
|
||||||
|
0..1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(0..7),
|
||||||
|
0..2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(0..9),
|
||||||
|
0..2
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(4..9),
|
||||||
|
0..2
|
||||||
|
);
|
||||||
|
|
||||||
|
std::panic::catch_unwind(|| {
|
||||||
|
let linebreaker = LineBreaker::new("abc def");
|
||||||
|
linebreaker.linebreaks_in_range_after_current_offset(5..2);
|
||||||
|
})
|
||||||
|
.expect_err("Reversed range should cause an assertion failure.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_linebreaker_stateful_advance() {
|
||||||
|
let mut linebreaker = LineBreaker::new("abc d def");
|
||||||
|
assert_eq!(linebreaker.linebreaks, [4, 6, 9]);
|
||||||
|
assert!(linebreaker.advance_to_linebreaks_in_range(0..7) == &[4, 6]);
|
||||||
|
assert!(linebreaker.advance_to_linebreaks_in_range(8..9).is_empty());
|
||||||
|
|
||||||
|
// We've already advanced, so a range from the beginning shouldn't affect things.
|
||||||
|
assert!(linebreaker.advance_to_linebreaks_in_range(0..9).is_empty());
|
||||||
|
|
||||||
|
linebreaker.current_offset = 0;
|
||||||
|
|
||||||
|
// Sending a value out of range shoudn't break things.
|
||||||
|
assert!(linebreaker.advance_to_linebreaks_in_range(0..999) == &[4, 6]);
|
||||||
|
|
||||||
|
linebreaker.current_offset = 0;
|
||||||
|
|
||||||
|
std::panic::catch_unwind(|| {
|
||||||
|
let mut linebreaker = LineBreaker::new("abc d def");
|
||||||
|
linebreaker.advance_to_linebreaks_in_range(2..0);
|
||||||
|
})
|
||||||
|
.expect_err("Reversed range should cause an assertion failure.");
|
||||||
|
}
|
|
@ -70,6 +70,7 @@
|
||||||
|
|
||||||
pub mod construct;
|
pub mod construct;
|
||||||
pub mod line;
|
pub mod line;
|
||||||
|
mod line_breaker;
|
||||||
pub mod text_run;
|
pub mod text_run;
|
||||||
|
|
||||||
use std::cell::OnceCell;
|
use std::cell::OnceCell;
|
||||||
|
@ -84,6 +85,7 @@ use line::{
|
||||||
layout_line_items, AbsolutelyPositionedLineItem, AtomicLineItem, FloatLineItem,
|
layout_line_items, AbsolutelyPositionedLineItem, AtomicLineItem, FloatLineItem,
|
||||||
InlineBoxLineItem, LineItem, LineItemLayoutState, LineMetrics, TextRunLineItem,
|
InlineBoxLineItem, LineItem, LineItemLayoutState, LineMetrics, TextRunLineItem,
|
||||||
};
|
};
|
||||||
|
use line_breaker::LineBreaker;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use servo_arc::Arc;
|
use servo_arc::Arc;
|
||||||
use style::computed_values::text_wrap_mode::T as TextWrapMode;
|
use style::computed_values::text_wrap_mode::T as TextWrapMode;
|
||||||
|
@ -1593,13 +1595,13 @@ impl InlineFormattingContext {
|
||||||
let text_content: String = builder.text_segments.into_iter().collect();
|
let text_content: String = builder.text_segments.into_iter().collect();
|
||||||
let mut font_metrics = Vec::new();
|
let mut font_metrics = Vec::new();
|
||||||
|
|
||||||
let mut linebreaker = None;
|
let mut new_linebreaker = LineBreaker::new(text_content.as_str());
|
||||||
inline_formatting_context.foreach(|iter_item| match iter_item {
|
inline_formatting_context.foreach(|iter_item| match iter_item {
|
||||||
InlineFormattingContextIterItem::Item(InlineLevelBox::TextRun(ref mut text_run)) => {
|
InlineFormattingContextIterItem::Item(InlineLevelBox::TextRun(ref mut text_run)) => {
|
||||||
text_run.break_and_shape(
|
text_run.segment_and_shape(
|
||||||
&text_content[text_run.text_range.clone()],
|
&text_content,
|
||||||
&layout_context.font_context,
|
&layout_context.font_context,
|
||||||
&mut linebreaker,
|
&mut new_linebreaker,
|
||||||
&mut font_metrics,
|
&mut font_metrics,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -18,13 +18,13 @@ use servo_arc::Arc;
|
||||||
use style::computed_values::text_rendering::T as TextRendering;
|
use style::computed_values::text_rendering::T as TextRendering;
|
||||||
use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
|
use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
|
||||||
use style::computed_values::word_break::T as WordBreak;
|
use style::computed_values::word_break::T as WordBreak;
|
||||||
use style::properties::style_structs::InheritedText;
|
|
||||||
use style::properties::ComputedValues;
|
use style::properties::ComputedValues;
|
||||||
use style::str::char_is_whitespace;
|
use style::str::char_is_whitespace;
|
||||||
use style::values::computed::OverflowWrap;
|
use style::values::computed::OverflowWrap;
|
||||||
use unicode_script::Script;
|
use unicode_script::Script;
|
||||||
use xi_unicode::{linebreak_property, LineBreakLeafIter};
|
use xi_unicode::linebreak_property;
|
||||||
|
|
||||||
|
use super::line_breaker::LineBreaker;
|
||||||
use super::{FontKeyAndMetrics, InlineFormattingContextState};
|
use super::{FontKeyAndMetrics, InlineFormattingContextState};
|
||||||
use crate::fragment_tree::BaseFragmentInfo;
|
use crate::fragment_tree::BaseFragmentInfo;
|
||||||
|
|
||||||
|
@ -84,8 +84,8 @@ pub(crate) struct TextRunSegment {
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub script: Script,
|
pub script: Script,
|
||||||
|
|
||||||
/// The range of bytes in the [`TextRun`]'s text that this segment covers.
|
/// The range of bytes in the parent [`super::InlineFormattingContext`]'s text content.
|
||||||
pub range: ServoRange<ByteIndex>,
|
pub range: Range<usize>,
|
||||||
|
|
||||||
/// Whether or not the linebreaker said that we should allow a line break at the start of this
|
/// Whether or not the linebreaker said that we should allow a line break at the start of this
|
||||||
/// segment.
|
/// segment.
|
||||||
|
@ -96,11 +96,11 @@ pub(crate) struct TextRunSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextRunSegment {
|
impl TextRunSegment {
|
||||||
fn new(font_index: usize, script: Script, byte_index: ByteIndex) -> Self {
|
fn new(font_index: usize, script: Script, start_offset: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
script,
|
script,
|
||||||
font_index,
|
font_index,
|
||||||
range: ServoRange::new(byte_index, ByteIndex(0)),
|
range: start_offset..start_offset,
|
||||||
runs: Vec::new(),
|
runs: Vec::new(),
|
||||||
break_at_start: false,
|
break_at_start: false,
|
||||||
}
|
}
|
||||||
|
@ -167,6 +167,158 @@ impl TextRunSegment {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shape_and_push_range(
|
||||||
|
&mut self,
|
||||||
|
range: &Range<usize>,
|
||||||
|
formatting_context_text: &str,
|
||||||
|
segment_font: &FontRef,
|
||||||
|
options: &ShapingOptions,
|
||||||
|
) {
|
||||||
|
self.runs.push(GlyphRun {
|
||||||
|
glyph_store: segment_font.shape_text(&formatting_context_text[range.clone()], options),
|
||||||
|
range: ServoRange::new(
|
||||||
|
ByteIndex(range.start as isize),
|
||||||
|
ByteIndex(range.len() as isize),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape the text of this [`TextRunSegment`], first finding "words" for the shaper by processing
|
||||||
|
/// the linebreaks found in the owning [`super::InlineFormattingContext`]. Linebreaks are filtered,
|
||||||
|
/// based on the style of the parent inline box.
|
||||||
|
fn shape_text(
|
||||||
|
&mut self,
|
||||||
|
parent_style: &ComputedValues,
|
||||||
|
formatting_context_text: &str,
|
||||||
|
linebreaker: &mut LineBreaker,
|
||||||
|
shaping_options: &ShapingOptions,
|
||||||
|
font: FontRef,
|
||||||
|
) {
|
||||||
|
// Gather the linebreaks that apply to this segment from the inline formatting context's collection
|
||||||
|
// of line breaks. Also add a simulated break at the end of the segment in order to ensure the final
|
||||||
|
// piece of text is processed.
|
||||||
|
let range = self.range.clone();
|
||||||
|
let linebreaks = linebreaker.advance_to_linebreaks_in_range(self.range.clone());
|
||||||
|
let linebreak_iter = linebreaks.iter().chain(std::iter::once(&range.end));
|
||||||
|
|
||||||
|
self.runs.clear();
|
||||||
|
self.runs.reserve(linebreaks.len());
|
||||||
|
self.break_at_start = false;
|
||||||
|
|
||||||
|
let text_style = parent_style.get_inherited_text().clone();
|
||||||
|
let can_break_anywhere = text_style.word_break == WordBreak::BreakAll ||
|
||||||
|
text_style.overflow_wrap == OverflowWrap::Anywhere ||
|
||||||
|
text_style.overflow_wrap == OverflowWrap::BreakWord;
|
||||||
|
|
||||||
|
let mut last_slice_end = self.range.start;
|
||||||
|
for break_index in linebreak_iter {
|
||||||
|
if *break_index == self.range.start {
|
||||||
|
self.break_at_start = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend the slice to the next UAX#14 line break opportunity.
|
||||||
|
let mut slice = last_slice_end..*break_index;
|
||||||
|
let word = &formatting_context_text[slice.clone()];
|
||||||
|
|
||||||
|
// Split off any trailing whitespace into a separate glyph run.
|
||||||
|
let mut whitespace = slice.end..slice.end;
|
||||||
|
let mut rev_char_indices = word.char_indices().rev().peekable();
|
||||||
|
let ends_with_newline = rev_char_indices
|
||||||
|
.peek()
|
||||||
|
.map_or(false, |&(_, character)| character == '\n');
|
||||||
|
if let Some((first_white_space_index, first_white_space_character)) = rev_char_indices
|
||||||
|
.take_while(|&(_, character)| char_is_whitespace(character))
|
||||||
|
.last()
|
||||||
|
{
|
||||||
|
whitespace.start = slice.start + first_white_space_index;
|
||||||
|
|
||||||
|
// If line breaking for a piece of text that has `white-space-collapse: break-spaces` there
|
||||||
|
// is a line break opportunity *after* every preserved space, but not before. This means
|
||||||
|
// that we should not split off the first whitespace, unless that white-space is a preserved
|
||||||
|
// newline.
|
||||||
|
//
|
||||||
|
// An exception to this is if the style tells us that we can break in the middle of words.
|
||||||
|
if text_style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces &&
|
||||||
|
first_white_space_character != '\n' &&
|
||||||
|
!can_break_anywhere
|
||||||
|
{
|
||||||
|
whitespace.start += first_white_space_character.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
slice.end = whitespace.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no whitespace and `word-break` is set to `keep-all`, try increasing the slice.
|
||||||
|
// TODO: This should only happen for CJK text.
|
||||||
|
let can_break_anywhere = text_style.word_break == WordBreak::BreakAll ||
|
||||||
|
text_style.overflow_wrap == OverflowWrap::Anywhere ||
|
||||||
|
text_style.overflow_wrap == OverflowWrap::BreakWord;
|
||||||
|
if whitespace.is_empty() &&
|
||||||
|
*break_index != self.range.end &&
|
||||||
|
text_style.word_break == WordBreak::KeepAll &&
|
||||||
|
!can_break_anywhere
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only advance the last_slice_end if we are not going to try to expand the slice.
|
||||||
|
last_slice_end = *break_index;
|
||||||
|
|
||||||
|
// Push the non-whitespace part of the range.
|
||||||
|
if !slice.is_empty() {
|
||||||
|
self.shape_and_push_range(&slice, formatting_context_text, &font, shaping_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
if whitespace.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut options = *shaping_options;
|
||||||
|
options
|
||||||
|
.flags
|
||||||
|
.insert(ShapingFlags::IS_WHITESPACE_SHAPING_FLAG);
|
||||||
|
|
||||||
|
// If `white-space-collapse: break-spaces` is active, insert a line breaking opportunity
|
||||||
|
// between each white space character in the white space that we trimmed off.
|
||||||
|
if text_style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces {
|
||||||
|
let start_index = whitespace.start;
|
||||||
|
for (index, character) in formatting_context_text[whitespace].char_indices() {
|
||||||
|
let index = start_index + index;
|
||||||
|
self.shape_and_push_range(
|
||||||
|
&(index..index + character.len_utf8()),
|
||||||
|
formatting_context_text,
|
||||||
|
&font,
|
||||||
|
&options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The breaker breaks after every newline, so either there is none,
|
||||||
|
// or there is exactly one at the very end. In the latter case,
|
||||||
|
// split it into a different run. That's because shaping considers
|
||||||
|
// a newline to have the same advance as a space, but during layout
|
||||||
|
// we want to treat the newline as having no advance.
|
||||||
|
if ends_with_newline && whitespace.len() > 1 {
|
||||||
|
self.shape_and_push_range(
|
||||||
|
&(whitespace.start..whitespace.end - 1),
|
||||||
|
formatting_context_text,
|
||||||
|
&font,
|
||||||
|
&options,
|
||||||
|
);
|
||||||
|
self.shape_and_push_range(
|
||||||
|
&(whitespace.end - 1..whitespace.end),
|
||||||
|
formatting_context_text,
|
||||||
|
&font,
|
||||||
|
&options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.shape_and_push_range(&whitespace, formatting_context_text, &font, &options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextRun {
|
impl TextRun {
|
||||||
|
@ -185,14 +337,13 @@ impl TextRun {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn break_and_shape(
|
pub(super) fn segment_and_shape(
|
||||||
&mut self,
|
&mut self,
|
||||||
text_content: &str,
|
formatting_context_text: &str,
|
||||||
font_context: &FontContext<FontCacheThread>,
|
font_context: &FontContext<FontCacheThread>,
|
||||||
linebreaker: &mut Option<LineBreakLeafIter>,
|
linebreaker: &mut LineBreaker,
|
||||||
font_cache: &mut Vec<FontKeyAndMetrics>,
|
font_cache: &mut Vec<FontKeyAndMetrics>,
|
||||||
) {
|
) {
|
||||||
let segment_results = self.segment_text(text_content, font_context, font_cache);
|
|
||||||
let inherited_text_style = self.parent_style.get_inherited_text().clone();
|
let inherited_text_style = self.parent_style.get_inherited_text().clone();
|
||||||
let letter_spacing = if inherited_text_style.letter_spacing.0.px() != 0. {
|
let letter_spacing = if inherited_text_style.letter_spacing.0.px() != 0. {
|
||||||
Some(app_units::Au::from(inherited_text_style.letter_spacing.0))
|
Some(app_units::Au::from(inherited_text_style.letter_spacing.0))
|
||||||
|
@ -208,13 +359,12 @@ impl TextRun {
|
||||||
flags.insert(ShapingFlags::IGNORE_LIGATURES_SHAPING_FLAG);
|
flags.insert(ShapingFlags::IGNORE_LIGATURES_SHAPING_FLAG);
|
||||||
flags.insert(ShapingFlags::DISABLE_KERNING_SHAPING_FLAG)
|
flags.insert(ShapingFlags::DISABLE_KERNING_SHAPING_FLAG)
|
||||||
}
|
}
|
||||||
if inherited_text_style.word_break == WordBreak::KeepAll {
|
|
||||||
flags.insert(ShapingFlags::KEEP_ALL_FLAG);
|
|
||||||
}
|
|
||||||
|
|
||||||
let specified_word_spacing = &inherited_text_style.word_spacing;
|
let specified_word_spacing = &inherited_text_style.word_spacing;
|
||||||
let style_word_spacing: Option<Au> = specified_word_spacing.to_length().map(|l| l.into());
|
let style_word_spacing: Option<Au> = specified_word_spacing.to_length().map(|l| l.into());
|
||||||
let segments = segment_results
|
|
||||||
|
let segments = self
|
||||||
|
.segment_text_by_font(formatting_context_text, font_context, font_cache)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(mut segment, font)| {
|
.map(|(mut segment, font)| {
|
||||||
let word_spacing = style_word_spacing.unwrap_or_else(|| {
|
let word_spacing = style_word_spacing.unwrap_or_else(|| {
|
||||||
|
@ -224,20 +374,21 @@ impl TextRun {
|
||||||
.unwrap_or(gfx::font::LAST_RESORT_GLYPH_ADVANCE);
|
.unwrap_or(gfx::font::LAST_RESORT_GLYPH_ADVANCE);
|
||||||
specified_word_spacing.to_used_value(Au::from_f64_px(space_width))
|
specified_word_spacing.to_used_value(Au::from_f64_px(space_width))
|
||||||
});
|
});
|
||||||
|
|
||||||
let shaping_options = ShapingOptions {
|
let shaping_options = ShapingOptions {
|
||||||
letter_spacing,
|
letter_spacing,
|
||||||
word_spacing,
|
word_spacing,
|
||||||
script: segment.script,
|
script: segment.script,
|
||||||
flags,
|
flags,
|
||||||
};
|
};
|
||||||
(segment.runs, segment.break_at_start) = break_and_shape(
|
|
||||||
font,
|
|
||||||
&text_content[segment.range.begin().0 as usize..segment.range.end().0 as usize],
|
|
||||||
&inherited_text_style,
|
|
||||||
&shaping_options,
|
|
||||||
linebreaker,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
segment.shape_text(
|
||||||
|
&self.parent_style,
|
||||||
|
formatting_context_text,
|
||||||
|
linebreaker,
|
||||||
|
&shaping_options,
|
||||||
|
font,
|
||||||
|
);
|
||||||
segment
|
segment
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
@ -249,9 +400,9 @@ impl TextRun {
|
||||||
/// font and script. Fonts may differ when glyphs are found in fallback fonts. Fonts are stored
|
/// font and script. Fonts may differ when glyphs are found in fallback fonts. Fonts are stored
|
||||||
/// in the `font_cache` which is a cache of all font keys and metrics used in this
|
/// in the `font_cache` which is a cache of all font keys and metrics used in this
|
||||||
/// [`super::InlineFormattingContext`].
|
/// [`super::InlineFormattingContext`].
|
||||||
fn segment_text(
|
fn segment_text_by_font(
|
||||||
&mut self,
|
&mut self,
|
||||||
text_content: &str,
|
formatting_context_text: &str,
|
||||||
font_context: &FontContext<FontCacheThread>,
|
font_context: &FontContext<FontCacheThread>,
|
||||||
font_cache: &mut Vec<FontKeyAndMetrics>,
|
font_cache: &mut Vec<FontKeyAndMetrics>,
|
||||||
) -> Vec<(TextRunSegment, FontRef)> {
|
) -> Vec<(TextRunSegment, FontRef)> {
|
||||||
|
@ -259,15 +410,16 @@ impl TextRun {
|
||||||
let mut current: Option<(TextRunSegment, FontRef)> = None;
|
let mut current: Option<(TextRunSegment, FontRef)> = None;
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
let char_iterator = TwoCharsAtATimeIterator::new(text_content.chars());
|
let text_run_text = &formatting_context_text[self.text_range.clone()];
|
||||||
let mut next_byte_index = 0;
|
let char_iterator = TwoCharsAtATimeIterator::new(text_run_text.chars());
|
||||||
|
let mut next_byte_index = self.text_range.start;
|
||||||
for (character, next_character) in char_iterator {
|
for (character, next_character) in char_iterator {
|
||||||
let current_byte_index = next_byte_index;
|
let current_byte_index = next_byte_index;
|
||||||
next_byte_index += character.len_utf8();
|
next_byte_index += character.len_utf8();
|
||||||
|
|
||||||
let prevents_soft_wrap_opportunity =
|
let prevents_soft_wrap_opportunity =
|
||||||
char_prevents_soft_wrap_opportunity_when_before_or_after_atomic(character);
|
char_prevents_soft_wrap_opportunity_when_before_or_after_atomic(character);
|
||||||
if current_byte_index == 0 && prevents_soft_wrap_opportunity {
|
if current_byte_index == self.text_range.start && prevents_soft_wrap_opportunity {
|
||||||
self.prevent_soft_wrap_opportunity_at_start = true;
|
self.prevent_soft_wrap_opportunity_at_start = true;
|
||||||
}
|
}
|
||||||
self.prevent_soft_wrap_opportunity_at_end = prevents_soft_wrap_opportunity;
|
self.prevent_soft_wrap_opportunity_at_end = prevents_soft_wrap_opportunity;
|
||||||
|
@ -296,17 +448,19 @@ impl TextRun {
|
||||||
|
|
||||||
// Add the new segment and finish the existing one, if we had one. If the first
|
// Add the new segment and finish the existing one, if we had one. If the first
|
||||||
// characters in the run were control characters we may be creating the first
|
// characters in the run were control characters we may be creating the first
|
||||||
// segment in the middle of the run (ie the start should be 0).
|
// segment in the middle of the run (ie the start should be the start of this
|
||||||
|
// text run's text).
|
||||||
let start_byte_index = match current {
|
let start_byte_index = match current {
|
||||||
Some(_) => ByteIndex(current_byte_index as isize),
|
Some(_) => current_byte_index,
|
||||||
None => ByteIndex(0_isize),
|
None => self.text_range.start,
|
||||||
};
|
};
|
||||||
let new = (
|
let new = (
|
||||||
TextRunSegment::new(font_index, script, start_byte_index),
|
TextRunSegment::new(font_index, script, start_byte_index),
|
||||||
font,
|
font,
|
||||||
);
|
);
|
||||||
if let Some(mut finished) = current.replace(new) {
|
if let Some(mut finished) = current.replace(new) {
|
||||||
finished.0.range.extend_to(start_byte_index);
|
// The end of the previous segment is the start of the next one.
|
||||||
|
finished.0.range.end = current_byte_index;
|
||||||
results.push(finished);
|
results.push(finished);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -317,7 +471,7 @@ impl TextRun {
|
||||||
current = font_group.write().first(font_context).map(|font| {
|
current = font_group.write().first(font_context).map(|font| {
|
||||||
let font_index = add_or_get_font(&font, font_cache);
|
let font_index = add_or_get_font(&font, font_cache);
|
||||||
(
|
(
|
||||||
TextRunSegment::new(font_index, Script::Common, ByteIndex(0)),
|
TextRunSegment::new(font_index, Script::Common, self.text_range.start),
|
||||||
font,
|
font,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -325,10 +479,7 @@ impl TextRun {
|
||||||
|
|
||||||
// Extend the last segment to the end of the string and add it to the results.
|
// Extend the last segment to the end of the string and add it to the results.
|
||||||
if let Some(mut last_segment) = current.take() {
|
if let Some(mut last_segment) = current.take() {
|
||||||
last_segment
|
last_segment.0.range.end = self.text_range.end;
|
||||||
.0
|
|
||||||
.range
|
|
||||||
.extend_to(ByteIndex(text_content.len() as isize));
|
|
||||||
results.push(last_segment);
|
results.push(last_segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -463,129 +614,3 @@ where
|
||||||
Some((character, self.next_character))
|
Some((character, self.next_character))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn break_and_shape(
|
|
||||||
font: FontRef,
|
|
||||||
text: &str,
|
|
||||||
text_style: &InheritedText,
|
|
||||||
shaping_options: &ShapingOptions,
|
|
||||||
breaker: &mut Option<LineBreakLeafIter>,
|
|
||||||
) -> (Vec<GlyphRun>, bool) {
|
|
||||||
let mut glyphs = vec![];
|
|
||||||
|
|
||||||
if breaker.is_none() {
|
|
||||||
if text.is_empty() {
|
|
||||||
return (glyphs, true);
|
|
||||||
}
|
|
||||||
*breaker = Some(LineBreakLeafIter::new(text, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
let breaker = breaker.as_mut().unwrap();
|
|
||||||
|
|
||||||
let mut push_range = |range: &Range<usize>, options: &ShapingOptions| {
|
|
||||||
glyphs.push(GlyphRun {
|
|
||||||
glyph_store: font.shape_text(&text[range.clone()], options),
|
|
||||||
range: ServoRange::new(
|
|
||||||
ByteIndex(range.start as isize),
|
|
||||||
ByteIndex(range.len() as isize),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let can_break_anywhere = text_style.word_break == WordBreak::BreakAll ||
|
|
||||||
text_style.overflow_wrap == OverflowWrap::Anywhere ||
|
|
||||||
text_style.overflow_wrap == OverflowWrap::BreakWord;
|
|
||||||
|
|
||||||
let mut break_at_zero = false;
|
|
||||||
let mut last_slice_end = 0;
|
|
||||||
while last_slice_end != text.len() {
|
|
||||||
let (break_index, _is_hard_break) = breaker.next(text);
|
|
||||||
if break_index == 0 {
|
|
||||||
break_at_zero = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend the slice to the next UAX#14 line break opportunity.
|
|
||||||
let mut slice = last_slice_end..break_index;
|
|
||||||
let word = &text[slice.clone()];
|
|
||||||
|
|
||||||
// Split off any trailing whitespace into a separate glyph run.
|
|
||||||
let mut whitespace = slice.end..slice.end;
|
|
||||||
let mut rev_char_indices = word.char_indices().rev().peekable();
|
|
||||||
let ends_with_newline = rev_char_indices.peek().map_or(false, |&(_, c)| c == '\n');
|
|
||||||
if let Some((first_white_space_index, first_white_space_character)) = rev_char_indices
|
|
||||||
.take_while(|&(_, c)| char_is_whitespace(c))
|
|
||||||
.last()
|
|
||||||
{
|
|
||||||
whitespace.start = slice.start + first_white_space_index;
|
|
||||||
|
|
||||||
// If line breaking for a piece of text that has `white-space-collapse: break-spaces` there
|
|
||||||
// is a line break opportunity *after* every preserved space, but not before. This means
|
|
||||||
// that we should not split off the first whitespace, unless that white-space is a preserved
|
|
||||||
// newline.
|
|
||||||
//
|
|
||||||
// An exception to this is if the style tells us that we can break in the middle of words.
|
|
||||||
if text_style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces &&
|
|
||||||
first_white_space_character != '\n' &&
|
|
||||||
!can_break_anywhere
|
|
||||||
{
|
|
||||||
whitespace.start += first_white_space_character.len_utf8();
|
|
||||||
}
|
|
||||||
|
|
||||||
slice.end = whitespace.start;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's no whitespace and `word-break` is set to `keep-all`, try increasing the slice.
|
|
||||||
// TODO: This should only happen for CJK text.
|
|
||||||
let can_break_anywhere = text_style.word_break == WordBreak::BreakAll ||
|
|
||||||
text_style.overflow_wrap == OverflowWrap::Anywhere ||
|
|
||||||
text_style.overflow_wrap == OverflowWrap::BreakWord;
|
|
||||||
if whitespace.is_empty() &&
|
|
||||||
break_index != text.len() &&
|
|
||||||
text_style.word_break == WordBreak::KeepAll &&
|
|
||||||
!can_break_anywhere
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only advance the last_slice_end if we are not going to try to expand the slice.
|
|
||||||
last_slice_end = break_index;
|
|
||||||
|
|
||||||
// Push the non-whitespace part of the range.
|
|
||||||
if !slice.is_empty() {
|
|
||||||
push_range(&slice, shaping_options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if whitespace.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut options = *shaping_options;
|
|
||||||
options
|
|
||||||
.flags
|
|
||||||
.insert(ShapingFlags::IS_WHITESPACE_SHAPING_FLAG);
|
|
||||||
|
|
||||||
// If `white-space-collapse: break-spaces` is active, insert a line breaking opportunity
|
|
||||||
// between each white space character in the white space that we trimmed off.
|
|
||||||
if text_style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces {
|
|
||||||
let start_index = whitespace.start;
|
|
||||||
for (index, character) in text[whitespace].char_indices() {
|
|
||||||
let index = start_index + index;
|
|
||||||
push_range(&(index..index + character.len_utf8()), &options);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The breaker breaks after every newline, so either there is none,
|
|
||||||
// or there is exactly one at the very end. In the latter case,
|
|
||||||
// split it into a different run. That's because shaping considers
|
|
||||||
// a newline to have the same advance as a space, but during layout
|
|
||||||
// we want to treat the newline as having no advance.
|
|
||||||
if ends_with_newline && whitespace.len() > 1 {
|
|
||||||
push_range(&(whitespace.start..whitespace.end - 1), &options);
|
|
||||||
push_range(&(whitespace.end - 1..whitespace.end), &options);
|
|
||||||
} else {
|
|
||||||
push_range(&whitespace, &options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(glyphs, break_at_zero)
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
[line-break-anywhere-overrides-uax-behavior-015.html]
|
||||||
|
expected: FAIL
|
|
@ -1,2 +0,0 @@
|
||||||
[line-break-normal-015b.xht]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-break-strict-015b.xht]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-013.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-014.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-021.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-024.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-025.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-026.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-027.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-atomic-010.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[line-breaking-atomic-011.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-001-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-002-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-003-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-008-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-009-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-010-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-011-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-014-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-016-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-020-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-021-ref.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[shaping-022-ref.html]
|
|
||||||
expected: FAIL
|
|
2
tests/wpt/meta/css/css-text/shaping/shaping-001.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-001.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-001.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-002.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-002.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-002.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-003.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-003.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-003.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-008.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-008.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-008.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-014.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-014.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-014.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-016.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-016.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-016.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-017.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-017.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-017.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-018.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-018.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-018.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-020.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-020.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-020.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-021.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-021.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-021.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-022.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-022.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-022.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-023.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-023.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-023.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-024.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-024.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-024.html]
|
||||||
|
expected: FAIL
|
2
tests/wpt/meta/css/css-text/shaping/shaping-025.html.ini
Normal file
2
tests/wpt/meta/css/css-text/shaping/shaping-025.html.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[shaping-025.html]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,2 @@
|
||||||
|
[word-break-manual-001.html]
|
||||||
|
expected: FAIL
|
|
@ -1,2 +0,0 @@
|
||||||
[word-break-normal-002.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[word-break-normal-003.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[word-break-normal-th-000.html]
|
|
||||||
expected: FAIL
|
|
|
@ -1,2 +0,0 @@
|
||||||
[word-break-normal-th-001.html]
|
|
||||||
expected: FAIL
|
|
Loading…
Add table
Add a link
Reference in a new issue