blob: 4a3272793b0471fe1515ea8591e2fc0e03a275c6 [file] [log] [blame]
/*
* Copyright (C) 2018 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
#include "config.h"
#include "InlineLineBreaker.h"
#if ENABLE(LAYOUT_FORMATTING_CONTEXT)
#include "FontCascade.h"
#include "Hyphenation.h"
#include "InlineItem.h"
#include "InlineTextItem.h"
#include "TextUtil.h"
namespace WebCore {
namespace Layout {
static inline bool isContentWrappingAllowed(const RenderStyle& style)
{
return style.whiteSpace() != WhiteSpace::Pre && style.whiteSpace() != WhiteSpace::NoWrap;
}
static inline bool isTrailingWhitespaceWithPreWrap(const InlineItem& trailingInlineItem)
{
if (!trailingInlineItem.isText())
return false;
return trailingInlineItem.style().whiteSpace() == WhiteSpace::PreWrap && downcast<InlineTextItem>(trailingInlineItem).isWhitespace();
}
LineBreaker::BreakingContext LineBreaker::breakingContextForInlineContent(const Content& candidateRuns, const LineStatus& lineStatus)
{
ASSERT(!candidateRuns.isEmpty());
if (candidateRuns.width() <= lineStatus.availableWidth)
return { BreakingContext::ContentBreak::Keep, { } };
if (candidateRuns.hasTrailingTrimmableContent()) {
// First check if the content fits without the trailing trimmable part.
if (candidateRuns.nonTrimmableWidth() <= lineStatus.availableWidth)
return { BreakingContext::ContentBreak::Keep, { } };
// Now check if we can trim the line too.
if (lineStatus.lineHasFullyTrimmableTrailingRun && candidateRuns.isTrailingContentFullyTrimmable()) {
// If this new content is fully trimmable, it shoud surely fit.
return { BreakingContext::ContentBreak::Keep, { } };
}
} else if (lineStatus.trimmableWidth && candidateRuns.hasNonContentRunsOnly()) {
// Let's see if the non-content runs fit when the line has trailing trimmable content
// "text content <span style="padding: 1px"></span>" <- the <span></span> runs could fit after trimming the trailing whitespace.
if (candidateRuns.width() <= lineStatus.availableWidth + lineStatus.trimmableWidth)
return { BreakingContext::ContentBreak::Keep, { } };
}
if (candidateRuns.hasTextContentOnly()) {
auto& runs = candidateRuns.runs();
if (auto partialTrailingContent = wordBreakingBehavior(runs, lineStatus.availableWidth))
return { BreakingContext::ContentBreak::Split, partialTrailingContent };
// If we did not manage to break this content, we still need to decide whether keep it or wrap it to the next line.
// FIXME: Keep tracking the last breaking opportunity where we can wrap the content:
// <span style="white-space: pre;">this fits</span> <span style="white-space: pre;">this does not fit but does not wrap either</span>
// ^^ could wrap at the whitespace position between the 2 inline containers.
auto contentShouldWrap = !lineStatus.lineIsEmpty && isContentWrappingAllowed(runs[0].inlineItem.style());
// FIXME: white-space: pre-wrap needs clarification. According to CSS Text Module Level 3, content wrapping is as 'normal' but apparently
// we need to keep the overlapping whitespace on the line (and hang it I'd assume).
if (isTrailingWhitespaceWithPreWrap(runs.last().inlineItem))
contentShouldWrap = false;
return { contentShouldWrap ? BreakingContext::ContentBreak::Wrap : BreakingContext::ContentBreak::Keep, { } };
}
// First non-text inline content always stays on line.
return { lineStatus.lineIsEmpty ? BreakingContext::ContentBreak::Keep : BreakingContext::ContentBreak::Wrap, { } };
}
bool LineBreaker::shouldWrapFloatBox(LayoutUnit floatLogicalWidth, LayoutUnit availableWidth, bool lineIsEmpty)
{
return !lineIsEmpty && floatLogicalWidth > availableWidth;
}
Optional<LineBreaker::BreakingContext::PartialTrailingContent> LineBreaker::wordBreakingBehavior(const Content::RunList& runs, LayoutUnit availableWidth) const
{
// Check where the overflow occurs and use the corresponding style to figure out the breaking behaviour.
// <span style="word-break: normal">first</span><span style="word-break: break-all">second</span><span style="word-break: normal">third</span>
LayoutUnit runsWidth;
for (unsigned i = 0; i < runs.size(); ++i) {
auto& run = runs[i];
ASSERT(run.inlineItem.isText() || run.inlineItem.isContainerStart() || run.inlineItem.isContainerEnd());
runsWidth += run.logicalWidth;
// FIXME: In case of multiple inline items, we might not be able to split the content where it overflows (<span style="white-space: normal">this still fits the line</span<span style="white-space: pre">this is not anymore, but can't split</span)
// so we need to look for split positions even runsWidth <= availableWidth.
if (runsWidth <= availableWidth)
continue;
// Let's find the first breaking opportunity starting from this overflown inline item.
if (!run.inlineItem.isText()) {
// Can't split horizontal spacing -> e.g. <span style="padding-right: 100px;">textcontent</span>, if the [container end] is the overflown inline item
// we need to check if there's another inline item beyond the [container end] to split.
continue;
}
// Do not try to split 'pre' and 'no-wrap' content.
if (!isContentWrappingAllowed(run.inlineItem.style()))
continue;
// At this point the available width can very well be negative e.g. when some part of the continuous text content can not be broken into parts ->
// <span style="word-break: keep-all">textcontentwithnobreak</span><span>textcontentwithyesbreak</span>
// When the first span computes longer than the available space, by the time we get to the second span, the adjusted available space becomes negative.
auto adjustedAvailableWidth = std::max(0_lu, availableWidth - runsWidth + run.logicalWidth);
if (auto leftSide = tryBreakingTextRun(run, adjustedAvailableWidth))
return BreakingContext::PartialTrailingContent { i, leftSide->length, leftSide->logicalWidth, leftSide->needsHyphen };
return { };
}
// We did not manage to break in this sequence of runs.
return { };
}
Optional<LineBreaker::LeftSide> LineBreaker::tryBreakingTextRun(const Content::Run& overflowRun, LayoutUnit availableWidth) const
{
ASSERT(overflowRun.inlineItem.isText());
auto& style = overflowRun.inlineItem.style();
auto breakWords = style.wordBreak();
if (breakWords == WordBreak::KeepAll)
return { };
auto& inlineTextItem = downcast<InlineTextItem>(overflowRun.inlineItem);
if (breakWords == WordBreak::BreakAll) {
// FIXME: Pass in the content logical left to be able to measure tabs.
auto splitData = TextUtil::split(inlineTextItem.layoutBox(), inlineTextItem.start(), inlineTextItem.length(), overflowRun.logicalWidth, availableWidth, { });
return LeftSide { splitData.length, splitData.logicalWidth, false };
}
// Find the hyphen position as follows:
// 1. Split the text by taking the hyphen width into account
// 2. Find the last hyphen position before the split position
if (n_hyphenationIsDisabled || style.hyphens() != Hyphens::Auto || !canHyphenate(style.locale()))
return { };
auto runLength = inlineTextItem.length();
unsigned limitBefore = style.hyphenationLimitBefore() == RenderStyle::initialHyphenationLimitBefore() ? 0 : style.hyphenationLimitBefore();
unsigned limitAfter = style.hyphenationLimitAfter() == RenderStyle::initialHyphenationLimitAfter() ? 0 : style.hyphenationLimitAfter();
// Check if this run can accommodate the before/after limits at all before start measuring text.
if (limitBefore >= runLength || limitAfter >= runLength || limitBefore + limitAfter > runLength)
return { };
auto& fontCascade = style.fontCascade();
// FIXME: We might want to cache the hyphen width.
auto hyphenWidth = LayoutUnit { fontCascade.width(TextRun { StringView { style.hyphenString() } }) };
auto availableWidthExcludingHyphen = availableWidth - hyphenWidth;
// For spaceWidth() see webkit.org/b/169613
if (availableWidthExcludingHyphen <= 0 || !enoughWidthForHyphenation(availableWidthExcludingHyphen + fontCascade.spaceWidth(), fontCascade.pixelSize()))
return { };
auto splitData = TextUtil::split(inlineTextItem.layoutBox(), inlineTextItem.start(), runLength, overflowRun.logicalWidth, availableWidthExcludingHyphen, { });
if (splitData.length < limitBefore)
return { };
auto textContent = inlineTextItem.layoutBox().textContext()->content;
// Adjust before index to accommodate the limit-after value (it's the last potential hyphen location in this run).
auto hyphenBefore = std::min(splitData.length, runLength - limitAfter) + 1;
unsigned hyphenLocation = lastHyphenLocation(StringView(textContent).substring(inlineTextItem.start(), inlineTextItem.length()), hyphenBefore, style.locale());
if (!hyphenLocation || hyphenLocation < limitBefore)
return { };
return LeftSide { hyphenLocation, TextUtil::width(inlineTextItem.layoutBox(), inlineTextItem.start(), hyphenLocation), true };
}
bool LineBreaker::Content::isAtContentBoundary(const InlineItem& inlineItem, const Content& content)
{
// https://drafts.csswg.org/css-text-3/#line-break-details
// Figure out if the new incoming content puts the uncommitted content on commit boundary.
// e.g. <span>continuous</span> <- uncomitted content ->
// [inline container start][text content][inline container end]
// An incoming <img> box would enable us to commit the "<span>continuous</span>" content
// while additional text content would not.
ASSERT(!inlineItem.isFloat() && !inlineItem.isLineBreak());
if (content.isEmpty()) {
// Can't decide it yet.
return false;
}
auto* lastUncomittedContent = &content.runs().last().inlineItem;
if (inlineItem.isText()) {
// any content' ' -> whitespace is always a commit boundary.
if (downcast<InlineTextItem>(inlineItem).isWhitespace())
return true;
// <span>text -> the inline container start and the text content form an unbreakable continuous content.
if (lastUncomittedContent->isContainerStart())
return false;
// </span>text -> need to check what's before the </span>.
// text</span>text -> continuous content
// <img></span>text -> commit bounday
if (lastUncomittedContent->isContainerEnd()) {
auto& runs = content.runs();
// text</span><span></span></span>text -> check all the way back until we hit either a box or some text
for (auto i = content.size(); i--;) {
auto& previousInlineItem = runs[i].inlineItem;
if (previousInlineItem.isContainerStart() || previousInlineItem.isContainerEnd())
continue;
ASSERT(previousInlineItem.isText() || previousInlineItem.isBox());
lastUncomittedContent = &previousInlineItem;
break;
}
// Did not find any content (e.g. <span></span>text)
if (lastUncomittedContent->isContainerEnd())
return false;
}
// texttext -> continuous content.
// ' 'text -> commit boundary.
if (lastUncomittedContent->isText())
return downcast<InlineTextItem>(*lastUncomittedContent).isWhitespace();
// <img>text -> the inline box is on a commit boundary.
if (lastUncomittedContent->isBox())
return true;
ASSERT_NOT_REACHED();
}
if (inlineItem.isBox()) {
// <span><img> -> the inline container start and the content form an unbreakable continuous content.
if (lastUncomittedContent->isContainerStart())
return false;
// </span><img> -> ok to commit the </span>.
if (lastUncomittedContent->isContainerEnd())
return true;
// <img>text and <img><img> -> these combinations are ok to commit.
if (lastUncomittedContent->isText() || lastUncomittedContent->isBox())
return true;
ASSERT_NOT_REACHED();
}
if (inlineItem.isContainerStart() || inlineItem.isContainerEnd()) {
// <span><span> or </span><span> -> can't commit the previous content yet.
if (lastUncomittedContent->isContainerStart() || lastUncomittedContent->isContainerEnd())
return false;
// ' '<span> -> let's commit the whitespace
// text<span> -> but not yet the non-whitespace; we need to know what comes next (e.g. text<span>text or text<span><img>).
if (lastUncomittedContent->isText())
return downcast<InlineTextItem>(*lastUncomittedContent).isWhitespace();
// <img><span> -> it's ok to commit the inline box content.
// <img></span> -> the inline box and the closing inline container form an unbreakable continuous content.
if (lastUncomittedContent->isBox())
return inlineItem.isContainerStart();
ASSERT_NOT_REACHED();
}
ASSERT_NOT_REACHED();
return true;
}
void LineBreaker::Content::append(const InlineItem& inlineItem, LayoutUnit logicalWidth)
{
ASSERT(!inlineItem.isFloat());
ASSERT(inlineItem.isLineBreak() || !isAtContentBoundary(inlineItem, *this));
m_continousRuns.append({ inlineItem, logicalWidth });
m_width += logicalWidth;
// Figure out the trailing trimmable state.
if (inlineItem.isBox() || inlineItem.isLineBreak())
m_trailingTrimmableContent.reset();
else if (inlineItem.isText()) {
auto& inlineTextItem = downcast<InlineTextItem>(inlineItem);
auto isFullyTrimmable = [&] {
return inlineTextItem.isWhitespace() && !TextUtil::shouldPreserveTrailingWhitespace(inlineTextItem.style());
};
if (isFullyTrimmable()) {
m_trailingTrimmableContent.width += logicalWidth;
m_trailingTrimmableContent.isFullyTrimmable = true;
} else if (auto trimmableWidth = inlineTextItem.style().letterSpacing()) {
m_trailingTrimmableContent.width = trimmableWidth;
m_trailingTrimmableContent.isFullyTrimmable = false;
} else
m_trailingTrimmableContent.reset();
}
}
void LineBreaker::Content::reset()
{
m_continousRuns.clear();
m_trailingTrimmableContent.reset();
m_width = 0_lu;
}
void LineBreaker::Content::trim(unsigned newSize)
{
for (auto i = m_continousRuns.size(); i--;)
m_width -= m_continousRuns[i].logicalWidth;
m_continousRuns.shrink(newSize);
}
bool LineBreaker::Content::hasTextContentOnly() const
{
// <span>text</span> is considered a text run even with the [container start][container end] inline items.
// Due to commit boundary rules, we just need to check the first non-typeless inline item (can't have both [img] and [text])
for (auto& run : m_continousRuns) {
auto& inlineItem = run.inlineItem;
if (inlineItem.isContainerStart() || inlineItem.isContainerEnd())
continue;
return inlineItem.isText();
}
return false;
}
bool LineBreaker::Content::hasNonContentRunsOnly() const
{
// <span></span> <- non content runs.
for (auto& run : m_continousRuns) {
auto& inlineItem = run.inlineItem;
if (inlineItem.isContainerStart() || inlineItem.isContainerEnd())
continue;
return false;
}
return true;
}
void LineBreaker::Content::TrailingTrimmableContent::reset()
{
isFullyTrimmable = false;
width = 0_lu;
}
}
}
#endif