Example E083 : text line wrap

Line wrapping corner cases: first-line offsets, indents, and hyphen handling at the break.

Line wrapping corner cases: first-line offsets, indents, and hyphen handling at the break.

PDF

Page 1 of E083_text_line_wrap.pdf

Source Code

<?php

declare(strict_types=1);

/**
 * E083_text_line_wrap.php
 *
 * Visual inspection grid for the line wrapping corner cases of
 * addTextCell/getTextCell: first line offset, break opportunities
 * and text direction.
 *
 * @since       2026-08-21
 * @category    Library
 * @package     Pdf
 * @author      Nicola Asuni <info@tecnick.com>
 * @copyright   2002-2026 Nicola Asuni - Tecnick.com LTD
 * @license     https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link        https://github.com/tecnickcom/tc-lib-pdf
 *
 * This file is part of tc-lib-pdf software library.
 */

// NOTE: local file reads (images, fonts, attachments) are restricted to an allowlist of
// trusted paths that covers this package tree, so run the examples in place. To read assets
// from other locations, list them in the 'allowedPaths' entry of the fileOptions constructor
// parameter (see E047_remote_resources_security.php).

// NOTE: run make fonts in the project root to generate the dependencies and example fonts.

// autoloader when using Composer
require __DIR__ . '/../vendor/autoload.php';

// define fonts directory
\define('K_PATH_FONTS', \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts'));

$pdf = new \Com\Tecnick\Pdf\Tcpdf();

$pdf->setCreator('tc-lib-pdf');
$pdf->setAuthor('Nicola Asuni');
$pdf->setSubject('tc-lib-pdf example: 083');
$pdf->setTitle('Text Line Wrap Corner Cases');
$pdf->setKeywords('TCPDF tc-lib-pdf example text line wrap offset indent hyphen');
$pdf->setPDFFilename('083_text_line_wrap.pdf');

// The metrics used to break the lines come from the current font of the stack,
// so each block selects its own font right before it is written.
$selectFont = function (string $family = 'helvetica', string $style = '', float $size = 11) use ($pdf): void {
    $font = $pdf->font->insert($pdf->pon, $family, $style, $size);
    $pdf->page->addContent($font['out']);
};

// The cell border shows whether the wrapped lines stay inside the given width.
$cellStyle = [
    'all' => [
        'lineWidth' => 0.3,
        'lineCap' => 'butt',
        'lineJoin' => 'miter',
        'dashArray' => [],
        'dashPhase' => 0,
        'lineColor' => '#4a5b70',
        'fillColor' => '#ffffff',
    ],
];

// The marker shows where the first line is asked to start.
$markStyle = [
    'lineWidth' => 0.3,
    'lineCap' => 'butt',
    'lineJoin' => 'miter',
    'dashArray' => [1, 1],
    'dashPhase' => 0,
    'lineColor' => '#cc2244',
];

$labelx = 8.0;
$labelw = 32.0;
$posx = 44.0;
$width = 150.0;
$height = 22.0;
$step = 27.0;

// The underscores do not allow a line break, so the test word cannot be broken.
$word = 'AAAAAAAAAA_BBBBBBBBBB_CCCCCCCCCC';
$text = 'Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda.';

/**
 * Draws one labelled case and returns the ordinate of the next one.
 *
 * @param array<string, mixed> $args Named arguments for addTextCell.
 */
$drawCase = function (string $label, float $posy, array $args) use (
    $pdf,
    $selectFont,
    $cellStyle,
    $markStyle,
    $labelx,
    $labelw,
    $posx,
    $width,
    $height,
    $step,
): float {
    $selectFont('helvetica', '', 7);
    $pdf->addTextCell(
        txt: $label,
        posx: $labelx,
        posy: $posy,
        width: $labelw,
        valign: \Com\Tecnick\Pdf\TextVAlign::Top,
        halign: \Com\Tecnick\Pdf\TextHAlign::Right,
        drawcell: false,
    );

    $family = isset($args['family']) && \is_string($args['family']) ? $args['family'] : 'helvetica';
    unset($args['family']);
    $selectFont($family);
    $pdf->addTextCell(...\array_merge([
        'posx' => $posx,
        'posy' => $posy,
        'width' => $width,
        'height' => $height,
        'valign' => \Com\Tecnick\Pdf\TextVAlign::Top,
        'styles' => $cellStyle,
    ], $args));
    $bbox = $pdf->getLastTextBBox();

    $offset = isset($args['offset']) && \is_float($args['offset']) ? $args['offset'] : 0.0;
    if ($offset > 0.0 && $offset < $width) {
        // The offset is taken from the side the paragraph starts from: the right one
        // for RTL. The alignment moves the glyphs inside the line, not that side.
        $fromright = ($args['forcedir'] ?? null) === \Com\Tecnick\Unicode\TextDirection::Rtl;
        $markx = $fromright ? $posx + $width - $offset : $posx + $offset;
        $pdf->page->addContent($pdf->graph->getLine($markx, $posy, $markx, $posy + $height, $markStyle));
    }

    $selectFont('helvetica', '', 7);
    $pdf->addTextCell(
        txt: \sprintf('%.1f .. %.1f mm', $bbox['x'], $bbox['x'] + $bbox['w']),
        posx: $labelx,
        posy: $posy + 8,
        width: $labelw,
        valign: \Com\Tecnick\Pdf\TextVAlign::Top,
        halign: \Com\Tecnick\Pdf\TextHAlign::Right,
        drawcell: false,
    );

    return $posy + $step;
};

/**
 * Draws one labelled HTML block twice, wide and narrow, and returns the ordinate
 * of the next case. The CSS text-indent maps to the first line offset.
 */
$drawHtmlCase = function (string $label, float $posy, string $html) use (
    $pdf,
    $selectFont,
    $cellStyle,
    $labelx,
    $labelw,
    $posx,
    $height,
    $step,
): float {
    $selectFont('helvetica', '', 7);
    $pdf->addTextCell(
        txt: $label,
        posx: $labelx,
        posy: $posy,
        width: $labelw,
        valign: \Com\Tecnick\Pdf\TextVAlign::Top,
        halign: \Com\Tecnick\Pdf\TextHAlign::Right,
        drawcell: false,
    );

    $selectFont('dejavusans');
    $pdf->addHTMLCell($html, $posx, $posy, 95.0, $height, null, $cellStyle);
    $selectFont('dejavusans');
    $pdf->addHTMLCell($html, $posx + 100.0, $posy, 50.0, $height, null, $cellStyle);

    return $posy + $step;
};

/**
 * Writes a page title and returns the ordinate of the first case.
 */
$drawTitle = function (string $title) use ($pdf, $selectFont, $labelx): float {
    $pdf->addPage();
    $selectFont('helvetica', 'B', 12);
    $pdf->addTextCell(
        txt: $title,
        posx: $labelx,
        posy: 12,
        width: 190,
        valign: \Com\Tecnick\Pdf\TextVAlign::Top,
        halign: \Com\Tecnick\Pdf\TextHAlign::Left,
        drawcell: false,
    );

    return 22.0;
};

// ----------
// First line offset: the offset shortens the first line only.
// ----------

$posy = $drawTitle('First line offset (the dashed mark is the requested start of the first line)');

// Measure the test word to leave it 2mm less room than it needs on the first line.
// getTextCell() returns the PDF code without writing it, and records the metrics.
$selectFont();
$pdf->getTextCell(txt: $word, posx: $posx, posy: $posy, width: $width);
$tight = $width - $pdf->getLastTextBBox()['w'] + 2.0;

$posy = $drawCase('no offset', $posy, [
    'txt' => $text,
]);
$posy = $drawCase('first word fits', $posy, [
    'txt' => $word . ' ' . $text,
    'offset' => $tight - 12.0,
]);
$posy = $drawCase('first word too wide', $posy, [
    'txt' => $word . ' ' . $text,
    'offset' => $tight,
]);
$posy = $drawCase('word wider than the cell', $posy, [
    'txt' => $word . $word . ' ' . $text,
    'offset' => $tight,
]);
$posy = $drawCase('offset over the width', $posy, [
    'txt' => $text,
    'offset' => $width + 20.0,
]);
$posy = $drawCase('negative offset (hanging)', $posy, [
    'txt' => $text,
    'offset' => -4.0,
]);
$posy = $drawCase('offset and line break', $posy, [
    'txt' => "Alpha beta\ngamma delta epsilon zeta eta theta iota kappa lambda mu nu xi.",
    'offset' => $tight,
]);
$posy = $drawCase('offset and justified', $posy, [
    'txt' => $word . ' ' . $text,
    'offset' => $tight,
    'halign' => \Com\Tecnick\Pdf\TextHAlign::Justify,
]);
$posy = $drawCase('offset and centered', $posy, [
    'txt' => $word . ' ' . $text,
    'offset' => $tight,
    'halign' => \Com\Tecnick\Pdf\TextHAlign::Center,
]);
// The offset shortens an LTR first line on the left, so every line still ends
// flush with the right edge of the cell.
$drawCase('offset and right aligned', $posy, [
    'txt' => $word . ' ' . $text,
    'offset' => $tight - 12.0,
    'halign' => \Com\Tecnick\Pdf\TextHAlign::Right,
]);

// ----------
// Break opportunities: where a line is allowed to end.
// ----------

$posy = $drawTitle('Break opportunities');

$longword = 'Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdolore';
$breakable = \str_replace(
    '-',
    "\u{200B}",
    'Lorem-ipsum-dolor-sitamet-consectetur-adipiscing-elit-seddo-eiusmod-tempor-incididunt-utlabore',
);

$posy = $drawCase('spaces only', $posy, [
    'txt' => $text . ' ' . $text,
]);
$posy = $drawCase('no break point: broken between two characters at the cell edge', $posy, [
    'txt' => $longword,
]);
$pdf->enableWordBreakFallback(false);
$posy = $drawCase('no break point, enableWordBreakFallback(false): the word overflows', $posy, [
    'txt' => $longword,
]);
$pdf->enableWordBreakFallback(true);
$posy = $drawCase('combining marks stay with their letter', $posy, [
    'txt' => \str_repeat("e\u{0301}", 90),
    'family' => 'dejavusans',
]);

// Break after every character to place a hyphenated break next to the cell edge.
$charPatterns = [];
foreach (\range('a', 'z') as $letter) {
    $charPatterns[$letter] = $letter . '1';
}

$pdf->setTexHyphenPatterns(patterns: $charPatterns);
$posy = $drawCase('hyphenated', $posy, [
    'txt' => $longword,
]);
$posy = $drawCase('hyphenated with offset', $posy, [
    'txt' => $longword,
    'offset' => 60.0,
]);
$pdf->setTexHyphenPatterns(patterns: []);

$pdf->enableZeroWidthBreakPoints(true);
$posy = $drawCase('automatic break points', $posy, [
    'txt' => 'https://www.example.com/a/very/long/path/that/has/no/space/in/it/at/all/and/ends/here/index.html',
]);
$pdf->enableZeroWidthBreakPoints(false);

$posy = $drawCase('zero width spaces', $posy, [
    'txt' => $breakable,
]);

$hebrew =
    "\u{05D0}\u{05D1}\u{05D2}\u{05D3} \u{05D4}\u{05D5}\u{05D6}\u{05D7} \u{05D8}\u{05D9}\u{05DA}\u{05DB} "
    . "\u{05DC}\u{05DD}\u{05DE}\u{05DF} \u{05E0}\u{05E1}\u{05E2}\u{05E3} \u{05E4}\u{05E5}\u{05E6}\u{05E7}";

$posy = $drawCase('RTL', $posy, [
    'txt' => $hebrew . ' ' . $hebrew,
    'family' => 'dejavusans',
    'halign' => \Com\Tecnick\Pdf\TextHAlign::Right,
    'forcedir' => \Com\Tecnick\Unicode\TextDirection::Rtl,
]);
$drawCase('RTL with offset', $posy, [
    'txt' => $hebrew . ' ' . $hebrew,
    'family' => 'dejavusans',
    'offset' => 60.0,
    'halign' => \Com\Tecnick\Pdf\TextHAlign::Right,
    'forcedir' => \Com\Tecnick\Unicode\TextDirection::Rtl,
]);

$posy = $drawTitle('Break opportunities after hyphens');

$compounds = \str_repeat('long-distance well-known state-of-the-art ', 5);
$posy = $drawCase('hyphenated compounds: lines may end after a hyphen', $posy, [
    'txt' => $compounds,
]);
$posy = $drawCase('no break in -5, 1-2, ABC-123 or -foo: lines end at spaces only', $posy, [
    'txt' => \str_repeat('values -5 and 1-2, code ABC-123 and option -foo; ', 4),
]);
$posy = $drawCase('non-breaking hyphen (U+2011): kept together', $posy, [
    'txt' => \str_repeat("long\u{2011}distance well\u{2011}known state\u{2011}of\u{2011}the\u{2011}art ", 5),
    'family' => 'dejavusans',
]);
$pdf->enableHyphenBreaks(false);
$posy = $drawCase('enableHyphenBreaks(false): lines end at spaces only', $posy, [
    'txt' => $compounds,
]);
$pdf->enableHyphenBreaks(true);
$posy = $drawCase('narrow cell: a compound wider than the cell breaks after its hyphens', $posy, [
    'txt' => 'Long-distance well-known state-of-the-art',
    'width' => 25.0,
]);

// ----------
// CSS text-indent: the first line offset of the HTML engine, taken from the side
// the block starts from. Every case is drawn on a wide and on a narrow block.
// ----------

$posy = $drawTitle('CSS text-indent: indented from the start side of the block');

$ltrbody = 'Alpha beta gamma delta epsilon zeta eta theta iota kappa.';
$rtlbody = $hebrew . ' ' . $hebrew;

$posy = $drawHtmlCase('LTR', $posy, '<div>' . $ltrbody . '</div>');
$posy = $drawHtmlCase('LTR indent', $posy, '<div style="text-indent:25mm">' . $ltrbody . '</div>');
$posy = $drawHtmlCase(
    'LTR centered indent',
    $posy,
    '<div style="text-align:center;text-indent:25mm">' . $ltrbody . '</div>',
);
$posy = $drawHtmlCase('LTR hanging', $posy, '<div style="text-indent:-4mm">' . $ltrbody . '</div>');
// em refers to the font size of the element, so these two indents are the same.
$posy = $drawHtmlCase('LTR indent 2em', $posy, '<div style="font-size:14pt;text-indent:2em">' . $ltrbody . '</div>');
$posy = $drawHtmlCase('LTR indent 28pt', $posy, '<div style="font-size:14pt;text-indent:28pt">' . $ltrbody . '</div>');
$posy = $drawHtmlCase('RTL', $posy, '<div dir="rtl">' . $rtlbody . '</div>');
$posy = $drawHtmlCase('RTL indent', $posy, '<div dir="rtl" style="text-indent:25mm">' . $rtlbody . '</div>');
$drawHtmlCase(
    'RTL centered indent',
    $posy,
    '<div dir="rtl" style="text-align:center;text-indent:25mm">' . $rtlbody . '</div>',
);

// ----------
// CSS text-indent each-line: the first line after each forced line break is also
// indented, while the lines wrapped automatically are not. The dashed marks are the
// indented start (30pt from the block start).
// ----------

$posy = $drawTitle('CSS text-indent each-line: indented again after each forced line break');

/**
 * Draws one HTML case with the dashed indent marks on the wide and on the narrow block.
 */
$drawIndentCase = function (string $label, float $posy, string $html) use (
    $pdf,
    $drawHtmlCase,
    $markStyle,
    $posx,
    $height,
): float {
    $next = $drawHtmlCase($label, $posy, $html);
    $indent = $pdf->toUnit(30.0);
    foreach ([$posx, $posx + 100.0] as $blockx) {
        $pdf->page->addContent($pdf->graph->getLine(
            $blockx + $indent,
            $posy,
            $blockx + $indent,
            $posy + $height,
            $markStyle,
        ));
    }

    return $next;
};

$lines = 'First line<br>Second line<br>Third line';
$posy = $drawIndentCase(
    'each-line (all three lines on the mark)',
    $posy,
    '<p style="text-indent:30pt each-line">' . $lines . '</p>',
);
$posy = $drawIndentCase(
    'each-line 30pt (keyword first, same result)',
    $posy,
    '<p style="text-indent:each-line 30pt">' . $lines . '</p>',
);
$posy = $drawIndentCase('30pt (first line only)', $posy, '<p style="text-indent:30pt">' . $lines . '</p>');
$posy = $drawIndentCase(
    'each-line with wrapped lines (only the first line and the line after the break)',
    $posy,
    '<p style="text-indent:30pt each-line">' . $ltrbody . '<br>Lambda mu nu xi.</p>',
);
$posy = $drawIndentCase(
    'each-line in pre (every line on the mark)',
    $posy,
    "<pre style=\"text-indent:30pt each-line\">First line\nSecond line\nThird line</pre>",
);
$posy = $drawIndentCase('30pt foo (invalid, no indent)', $posy, '<p style="text-indent:30pt foo">' . $lines . '</p>');
$drawIndentCase(
    '30pt hanging (not supported, no indent)',
    $posy,
    '<p style="text-indent:30pt hanging">' . $lines . '</p>',
);

// ----------
// Continuation flow: successive calls that carry on the same line.
// ----------

$flowy = $drawTitle('Continuation flow: each run starts where the previous one ended');
$selectFont();

$runs = [
    'Alpha beta gamma delta epsilon zeta.',
    'Eta theta iota kappa.',
    $word,
    'Lambda mu nu xi omicron pi rho sigma tau.',
    $word,
    'Upsilon phi chi psi omega.',
];

$flowh = $pdf->toUnit($pdf->font->getCurrentFont()['height']);
$spacew = $pdf->toUnit($pdf->font->getCharWidth(0x20));
$posy = $flowy;
$offset = 0.0;

foreach ($runs as $run) {
    $pdf->addTextCell(
        txt: $run,
        posx: $posx,
        posy: $posy,
        width: $width,
        offset: $offset,
        valign: \Com\Tecnick\Pdf\TextVAlign::Top,
        halign: \Com\Tecnick\Pdf\TextHAlign::Left,
        drawcell: false,
    );

    // The next run continues after the last rendered line of this one.
    // getLastBBox() is that line, while getLastTextBBox() would be the box around
    // every line of the run, which reaches further right whenever the run wrapped.
    $bbox = $pdf->getLastBBox();
    $offset = $bbox['x'] + $bbox['w'] + $spacew - $posx;
    $posy = $bbox['y'] + $bbox['h'] - $flowh;
}

$pdf->page->addContent($pdf->graph->getRect($posx, $flowy, $width, $posy + $flowh - $flowy, 'D', [
    'all' => $markStyle,
]));

// ----------

$rawpdf = $pdf->getOutPDFString();
$pdf->renderPDF(rawpdf: $rawpdf);