# -*- encoding: utf-8; frozen_string_literal: true -*-
#
#--
# This file is part of HexaPDF.
#
# HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
# Copyright (C) 2014-2021 Thomas Leitner
#
# HexaPDF is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3 as
# published by the Free Software Foundation with the addition of the
# following permission added to Section 15 as permitted in Section 7(a):
# FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
# THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
# INFRINGEMENT OF THIRD PARTY RIGHTS.
#
# HexaPDF is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with HexaPDF. If not, see .
#
# The interactive user interfaces in modified source and object code
# versions of HexaPDF must display Appropriate Legal Notices, as required
# under Section 5 of the GNU Affero General Public License version 3.
#
# In accordance with Section 7(b) of the GNU Affero General Public
# License, a covered work must retain the producer line in every PDF that
# is created or manipulated using HexaPDF.
#
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at .
#++
require 'hexapdf/layout/style'
require 'hexapdf/layout/text_shaper'
require 'hexapdf/layout/numeric_refinements'
module HexaPDF
module Layout
# A TextFragment describes an optionally kerned piece of text that shares the same font, font
# size and other properties.
#
# Its items are either glyph objects of the font or numeric values describing kerning
# information. All returned measurement values are in text space units. If the items or the
# style are changed, the #clear_cache has to be called. Otherwise the measurements may not be
# correct!
#
# The items of a text fragment may be frozen to indicate that the fragment is potentially used
# multiple times.
#
# The rectangle with the bottom left corner (#x_min, #y_min) and the top right corner (#x_max,
# #y_max) describes the minimum bounding box of the whole text fragment and is usually *not*
# equal to the box (0, 0)-(#width, #height).
class TextFragment
using NumericRefinements
# Creates a new TextFragment object for the given text, shapes it and returns it.
#
# The needed style of the text fragment can either be specified by the +style+ argument or via
# the +options+ (in which case a new Style object is created). Regardless of the way, the
# resulting style object needs at least the font set.
def self.create(text, style = nil, **options)
style = (style.nil? ? Style.new(**options) : style)
fragment = new(style.font.decode_utf8(text), style)
TextShaper.new.shape_text(fragment)
end
# The items (glyphs and kerning values) of the text fragment.
attr_accessor :items
# The style to be applied.
#
# Only the following properties are used:
#
# * Style#font
# * Style#font_size
# * Style#horizontal_scaling
# * Style#character_spacing
# * Style#word_spacing
# * Style#text_rise
# * Style#text_rendering_mode
# * Style#subscript
# * Style#superscript
# * Style#underline
# * Style#strikeout
# * Style#fill_color
# * Style#fill_alpha
# * Style#stroke_color
# * Style#stroke_alpha
# * Style#stroke_width
# * Style#stroke_cap_style
# * Style#stroke_join_style
# * Style#stroke_miter_limit
# * Style#stroke_dash_pattern
# * Style#underlay_callback
# * Style#overlay_callback
attr_reader :style
# Creates a new TextFragment object with the given items and style.
#
# The argument +style+ can either be a Style object or a hash of style options.
def initialize(items, style)
@items = items
@style = (style.kind_of?(Style) ? style : Style.new(**style))
end
# The precision used to determine whether two floats represent the same value.
PRECISION = 0.000001 # :nodoc:
# Draws the text onto the canvas at the given position.
#
# This method is the main styled text drawing facility and therefore some optimizations are
# done:
#
# * The text is drawn using HexaPDF::Content;:Canvas#show_glyphs_only which means that the
# text matrix is *not* updated. Therefore the caller must *not* rely on it!
#
# * All text style properties mentioned in the description of #style are set except if
# +ignore_text_properties+ is set to +true+. Note that this only applies to style properties
# that directly affect text drawing, so, for example, underlays/overlays and
# underlining/strikeout is always done.
#
# The caller should set +ignore_text_properties+ to +true+ if the graphics state hasn't been
# changed. This is the case, for example, if the last thing drawn was a text fragment with
# the same style.
#
# * It is assumed that the text matrix is not rotated, skewed, etc. so that setting the text
# position can be done using the optimal method.
def draw(canvas, x, y, ignore_text_properties: false)
style.underlays.draw(canvas, x, y + y_min, self) if style.underlays?
# Set general font related graphics state if necessary
unless ignore_text_properties
canvas.font(style.font, size: style.calculated_font_size).
horizontal_scaling(style.horizontal_scaling).
character_spacing(style.character_spacing).
word_spacing(style.word_spacing).
text_rise(style.calculated_text_rise).
text_rendering_mode(style.text_rendering_mode)
# Set fill and/or stroke related graphics state
canvas.opacity(fill_alpha: style.fill_alpha, stroke_alpha: style.stroke_alpha)
trm = canvas.text_rendering_mode
if trm.value.even? # text is filled
canvas.fill_color(style.fill_color)
end
if trm == :stroke || trm == :fill_stroke || trm == :stroke_clip || trm == :fill_stroke_clip
canvas.stroke_color(style.stroke_color).
line_width(style.stroke_width).
line_cap_style(style.stroke_cap_style).
line_join_style(style.stroke_join_style).
miter_limit(style.stroke_miter_limit).
line_dash_pattern(style.stroke_dash_pattern)
end
end
canvas.begin_text
tlm = canvas.graphics_state.tlm
tx = x - tlm.e
ty = y - tlm.f
if tx.abs < PRECISION
if (ty + canvas.graphics_state.leading).abs < PRECISION
canvas.move_text_cursor
else
canvas.move_text_cursor(offset: [0, ty], absolute: false)
end
elsif ty.abs < PRECISION
canvas.move_text_cursor(offset: [tx, 0], absolute: false)
else
canvas.move_text_cursor(offset: [x, y])
end
canvas.show_glyphs_only(items)
if style.underline? && style.underline
y_offset = style.calculated_underline_position
canvas.save_graphics_state do
canvas.stroke_color(style.fill_color).
line_width(style.calculated_underline_thickness).
line_cap_style(:butt).
line_dash_pattern(0).
line(x, y + y_offset, x + width, y + y_offset).
stroke
end
end
if style.strikeout? && style.strikeout
y_offset = style.calculated_strikeout_position
canvas.save_graphics_state do
canvas.stroke_color(style.fill_color).
line_width(style.calculated_strikeout_thickness).
line_cap_style(:butt).
line_dash_pattern(0).
line(x, y + y_offset, x + width, y + y_offset).
stroke
end
end
style.overlays.draw(canvas, x, y + y_min, self) if style.overlays?
end
# The minimum x-coordinate of the first glyph.
def x_min
@x_min ||= calculate_x_min
end
# The maximum x-coordinate of the last glyph.
def x_max
@x_max ||= calculate_x_max
end
# The minimum y-coordinate, calculated using the scaled descender of the font.
def y_min
style.scaled_y_min
end
# The maximum y-coordinate, calculated using the scaled ascender of the font.
def y_max
style.scaled_y_max
end
# The minimum y-coordinate of any item.
def exact_y_min
@exact_y_min ||= (@items.min_by(&:y_min)&.y_min || 0) *
style.calculated_font_size / 1000.0 + style.calculated_text_rise
end
# The maximum y-coordinate of any item.
def exact_y_max
@exact_y_max ||= (@items.max_by(&:y_max)&.y_max || 0) *
style.calculated_font_size / 1000.0 + style.calculated_text_rise
end
# The width of the text fragment.
#
# It is the sum of the widths of its items and is calculated by using the algorithm presented
# in PDF1.7 s9.4.4. By using kerning values as the first and/or last items, the text contained
# in the fragment may spill over the left and/or right boundary.
def width
@width ||= @items.sum {|item| style.scaled_item_width(item) }
end
# The height of the text fragment.
#
# It is calculated as the difference of the maximum of the +y_max+ values and the minimum of
# the +y_min+ values of the items. However, the text rise value is also taken into account so
# that the baseline is always *inside* the bounds. For example, if a large negative text rise
# value is used, the baseline will be equal to the top boundary; if a large positive value is
# used, it will be equal to the bottom boundary.
def height
@height ||= [y_max, 0].max - [y_min, 0].min
end
# Returns the vertical alignment inside a line which is always :text for text fragments.
#
# See Line for details.
def valign
:text
end
# Clears all cached values.
#
# This method needs to be called if the fragment's items or attributes are changed!
def clear_cache
@x_min = @x_max = @exact_y_min = @exact_y_max = @width = @height = nil
self
end
# :nodoc:
def inspect
"#<#{self.class.name} #{items.inspect}>"
end
private
def calculate_x_min
if !@items.empty? && !@items[0].kind_of?(Numeric)
@items[0].x_min * style.scaled_font_size
else
@items.inject(0) do |sum, item|
sum += item.x_min * style.scaled_font_size
break sum unless item.kind_of?(Numeric)
sum
end
end
end
def calculate_x_max
if !@items.empty? && !@items[0].kind_of?(Numeric)
width - scaled_glyph_right_side_bearing(@items[-1])
else
@items.reverse_each.inject(width) do |sum, item|
if item.kind_of?(Numeric)
sum + item * style.scaled_font_size
else
break sum - scaled_glyph_right_side_bearing(item)
end
end
end
end
def scaled_glyph_right_side_bearing(glyph)
(glyph.x_max <= 0 ? 0 : glyph.width - glyph.x_max) * style.scaled_font_size +
style.scaled_character_spacing +
(glyph.apply_word_spacing? ? style.scaled_word_spacing : 0)
end
end
end
end