diff --git a/.docker/php/php81/Dockerfile b/.docker/php/php81/Dockerfile new file mode 100644 index 0000000..55ecea0 --- /dev/null +++ b/.docker/php/php81/Dockerfile @@ -0,0 +1,27 @@ +# ---------------------- +# The FPM base container +# ---------------------- +FROM php:8.1-cli-alpine AS dev + +RUN apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + libxml2-dev + +# Install php extensions +RUN docker-php-ext-install \ + xml + +# Cleanup apk cache and temp files +RUN rm -rf /var/cache/apk/* /tmp/* + +# ---------------------- +# Composer install step +# ---------------------- + +# Get latest Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# ---------------------- +# The FPM production container +# ---------------------- +FROM dev diff --git a/.docker/php/php82/Dockerfile b/.docker/php/php82/Dockerfile new file mode 100644 index 0000000..09af90a --- /dev/null +++ b/.docker/php/php82/Dockerfile @@ -0,0 +1,27 @@ +# ---------------------- +# The FPM base container +# ---------------------- +FROM php:8.2-cli-alpine AS dev + +RUN apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + libxml2-dev + +# Install php extensions +RUN docker-php-ext-install \ + xml + +# Cleanup apk cache and temp files +RUN rm -rf /var/cache/apk/* /tmp/* + +# ---------------------- +# Composer install step +# ---------------------- + +# Get latest Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# ---------------------- +# The FPM production container +# ---------------------- +FROM dev diff --git a/.docker/php/php83/Dockerfile b/.docker/php/php83/Dockerfile new file mode 100644 index 0000000..a7d21a8 --- /dev/null +++ b/.docker/php/php83/Dockerfile @@ -0,0 +1,27 @@ +# ---------------------- +# The FPM base container +# ---------------------- +FROM php:8.3-cli-alpine AS dev + +RUN apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + libxml2-dev + +# Install php extensions +RUN docker-php-ext-install \ + xml + +# Cleanup apk cache and temp files +RUN rm -rf /var/cache/apk/* /tmp/* + +# ---------------------- +# Composer install step +# ---------------------- + +# Get latest Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# ---------------------- +# The FPM production container +# ---------------------- +FROM dev diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d437b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto + +/.docker export-ignore +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore +/docker-compose.yaml export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2ebd219 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: tests + +on: [ push, pull_request ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.1, 8.2, 8.3 ] + dependency-version: [ prefer-lowest, prefer-stable ] + + name: P${{ matrix.php }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ~/.composer/cache/files + key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: | + composer install --no-interaction + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/phpunit --testdox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19b41b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.idea/ +/vendor/ +.phpunit.result.cache +composer.lock +phpunit.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c70174d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023, Anton Komarev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..912e14e --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# PHP SVG Font + +

+Releases +Build +License +

+ +## Introduction + +PHP SVG Font files reader and manipulator. + +## Installation + +Pull in the package through Composer. + +```shell +composer require cybercog/php-svg-font +``` + +## Usage + +### Instantiate FontList object + +```php +$fontList = \Cog\SvgFont\FontList::ofFile(__DIR__ . '/DejaVuSans.svg'); +``` + +### Retrieve SvgFont object from the FontList + +```php +$font = \Cog\SvgFont\FontList::ofFile(__DIR__ . '/DejaVuSans.svg')->getById('DejaVuSans'); +``` + +## License + +- `PHP SVG Font` package is open-sourced software licensed under the [MIT license](LICENSE) by [Anton Komarev]. + +## About CyberCog + +[CyberCog] is a Social Unity of enthusiasts. Research the best solutions in product & software development is our passion. + +- [Follow us on Twitter](https://twitter.com/cybercog) + +CyberCog + +[Anton Komarev]: https://komarev.com +[CyberCog]: https://cybercog.su diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7648ccd --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "cybercog/php-svg-font", + "description": "PHP SVG font parser", + "type": "library", + "license": "MIT", + "keywords": [ + "svg", + "font", + "glyph", + "typography" + ], + "authors": [ + { + "name": "Anton Komarev", + "email": "anton@komarev.com", + "homepage": "https://komarev.com", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Cog\\SvgFont\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\Unit\\Cog\\SvgFont\\": "test/" + } + }, + "require": { + "php": "^8.1", + "cybercog/php-unicode": "^1.0" + }, + "require-dev": { + "ext-simplexml": "*", + "phpunit/phpunit": "^10.5" + }, + "suggest": { + "ext-simplexml": "*" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable" : true +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..3148856 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,34 @@ +version: "3.9" +services: + php81: + container_name: php-svg-font-lib-81 + image: php-svg-font-lib + build: + context: ./ + dockerfile: ./.docker/php81/Dockerfile + tty: true + working_dir: /app + volumes: + - ./:/app + + php82: + container_name: php-svg-font-lib-82 + image: php-svg-font-lib + build: + context: ./ + dockerfile: ./.docker/php82/Dockerfile + tty: true + working_dir: /app + volumes: + - ./:/app + + php83: + container_name: php-svg-font-lib-83 + image: php-svg-font-lib + build: + context: ./ + dockerfile: ./.docker/php83/Dockerfile + tty: true + working_dir: /app + volumes: + - ./:/app diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d23ff36 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + ./test/Unit + + + diff --git a/src/Font.php b/src/Font.php new file mode 100644 index 0000000..5321796 --- /dev/null +++ b/src/Font.php @@ -0,0 +1,75 @@ + $glyphMap + */ + public function __construct( + public readonly string $id, + private readonly int $horizontalAdvance, + private readonly FontFace $fontFace, + private readonly MissingGlyph $missingGlyph, + private readonly array $glyphMap = [], + ) { + if ($horizontalAdvance < 0) { + throw new \InvalidArgumentException( + "Font with id `$id` has negative horizontal advance", + ); + } + } + + public function computeStringWidth( + UnicodeString $string, + int $size, + float $letterSpacing = 0.0, + ): float { + $maxLineWidth = $lineWidth = 0; + + $characterList = $string->characterList(); + + foreach ($characterList as $character) { + if ($character->toDecimal() === self::UNICODE_CODE_POINT_LINE_FEED) { + $maxLineWidth = max($maxLineWidth, $lineWidth); + $lineWidth = 0; + continue; + } + + $lineWidth += $this->computeCharacterWidth($character, $size, $letterSpacing); + } + + return max($maxLineWidth, $lineWidth); + } + + private function computeCharacterWidth( + Character $character, + int $size, + float $letterSpacing = 0.0, + ): float { + $size = $size / $this->fontFace->unitsPerEm; + + $glyphHorizontalAdvance = $this->resolveGlyphHorizontalAdvance($character); + + $glyphWidth = $glyphHorizontalAdvance * $size; + $letterSpacingWidth = $this->fontFace->unitsPerEm * $letterSpacing * $size; + + return $glyphWidth + $letterSpacingWidth; + } + + private function resolveGlyphHorizontalAdvance( + Character $character, + ): int { + return isset($this->glyphMap[strval($character)]) + ? $this->glyphMap[strval($character)]->horizontalAdvance ?? $this->horizontalAdvance + : $this->missingGlyph->horizontalAdvance ?? $this->horizontalAdvance; + } +} diff --git a/src/FontFace.php b/src/FontFace.php new file mode 100644 index 0000000..d05820b --- /dev/null +++ b/src/FontFace.php @@ -0,0 +1,15 @@ + $fontList + */ + private function __construct( + private readonly array $fontList, + ) { + } + + /** + * @param list $fontList + */ + public static function of( + array $fontList, + ): self { + foreach ($fontList as $font) { + if (!($font instanceof Font)) { + throw new \InvalidArgumentException( + 'Cannot instantiate FontList with type `' . get_class($font) . '`' + ); + } + } + + return new self( + $fontList, + ); + } + + public static function ofFile( + string $filePath, + SvgFontFileParserInterface $fontFileParser = null, + ): self { + if ($fontFileParser === null) { + $fontFileParser = new SimpleXmlSvgFontFileParser(); + } + + $fontList = $fontFileParser->parseFile( + $filePath, + ); + + // TODO: Assert font list types + + return new self( + $fontList, + ); + } + + public function getById( + string $id, + ): Font { + foreach ($this->fontList as $font) { + if ($font->id === $id) { + return $font; + } + } + + throw new \DomainException( + "Cannot get unknown font with id `$id`", + ); + } +} diff --git a/src/Glyph.php b/src/Glyph.php new file mode 100644 index 0000000..92f97ec --- /dev/null +++ b/src/Glyph.php @@ -0,0 +1,22 @@ +registerXPathNamespace('svg', 'http://www.w3.org/2000/svg'); + + $fontList = []; + + $fontElements = $xml->xpath('//svg:defs/svg:font'); + + foreach ($fontElements as $fontElement) { + $fontList[] = $this->initFont($fontElement); + } + + return $fontList; + } + + private function initFont( + \SimpleXMLElement $fontElement, + ): Font { + $fontId = strval($fontElement[self::ATTRIBUTE_ID]); + $defaultHorizontalAdvance = intval($fontElement[self::ATTRIBUTE_HORIZ_ADV_X]); + $glyphMap = []; + + foreach ($fontElement as $fontChildElement) { + switch ($fontChildElement->getName()) { + case self::ELEMENT_NAME_FONT_FACE: + $fontFace = $this->initFontFace($fontChildElement); + break; + case self::ELEMENT_NAME_MISSING_GLYPH: + $missingGlyph = $this->initMissingGlyph($fontChildElement); + break; + case self::ELEMENT_NAME_GLYPH: + $unicode = strval($fontChildElement[self::ATTRIBUTE_UNICODE]); + + if ($unicode !== '') { + try { + $character = Character::of($unicode); + $glyphMap[$unicode] = $this->initGlyph($fontChildElement, $character); + } catch (\Exception $exception) { + // TODO: Add multiple character support + } + } + break; + } + } + + if (!isset($fontFace)) { + throw new \DomainException( + "SVG font with id `$fontId` missing `font-face` XML element", + ); + } + + if (!isset($missingGlyph)) { + throw new \DomainException( + "SVG font with id `$fontId` missing `missing-glyph` XML element", + ); + } + + return new Font( + $fontId, + $defaultHorizontalAdvance, + $fontFace, + $missingGlyph, + $glyphMap, + ); + } + + private function initFontFace( + \SimpleXMLElement $fontFaceElement, + ): FontFace { + $unitsPerEm = intval($fontFaceElement[self::ATTRIBUTE_UNITS_PER_EM]); + + if ($unitsPerEm === 0) { + $unitsPerEm = 1000; + } + + return new FontFace( + $unitsPerEm, + ); + } + + private function initGlyph( + \SimpleXMLElement $glyphElement, + Character $character, + ): Glyph { + $name = strval($glyphElement[self::ATTRIBUTE_GLYPH_NAME]); + + if ($name === '') { + $name = null; + } + + $horizontalAdvance = intval($glyphElement[self::ATTRIBUTE_HORIZ_ADV_X]); + + if ($horizontalAdvance === 0) { + $horizontalAdvance = null; + } + + return new Glyph( + $character, + $name, + $horizontalAdvance, + ); + } + + private function initMissingGlyph( + \SimpleXMLElement $missingGlyphElement, + ): MissingGlyph { + $horizontalAdvance = intval($missingGlyphElement[self::ATTRIBUTE_HORIZ_ADV_X]); + + if ($horizontalAdvance === 0) { + $horizontalAdvance = null; + } + + return new MissingGlyph( + $horizontalAdvance, + ); + } +} diff --git a/src/Parser/SvgFontFileParserInterface.php b/src/Parser/SvgFontFileParserInterface.php new file mode 100644 index 0000000..17ab291 --- /dev/null +++ b/src/Parser/SvgFontFileParserInterface.php @@ -0,0 +1,20 @@ + + */ + public function parseFile( + string $filePath, + ): array; +} diff --git a/test/Unit/SvgFontTest.php b/test/Unit/SvgFontTest.php new file mode 100644 index 0000000..4b84231 --- /dev/null +++ b/test/Unit/SvgFontTest.php @@ -0,0 +1,65 @@ +getFontById('Bagnard'); + + $this->assertSame( + $expectedWidth, + $font->computeStringWidth( + UnicodeString::of($string), + $fontSize, + $letterSpacing, + ), + ); + } + + public static function provideItCanComputeStringWidth(): array + { + return [ + [0, 'Zero-width', 0, 0.0], + [8.192, 'a', 16, 0.0], + [9.712, 'b', 16, 0.0], + [9.44, '4', 16, 0.0], + [4.816, '.', 16, 0.0], + [4.816, ',', 16, 0.0], + [41.072, 'Hello', 16, 0.0], + [43.344, 'world', 16, 0.0], + [82.144, 'Hello', 32, 0.0], + [86.688, 'world', 32, 0.0], + ]; + } + + protected function getFontById( + string $fontId, + ): Font { + $fonts = [ + 'Bagnard' => 'BagnardSans.svg', + ]; + + $fontFileName = $fonts[$fontId] ?? null; + + if ($fontFileName === null) { + throw new \DomainException("Unknown test case font id `$fontId`"); + } + + return FontList::ofFile(__DIR__ . '/../resource/' . $fontFileName) + ->getById($fontId); + } +} diff --git a/test/resource/BagnardSans.svg b/test/resource/BagnardSans.svg new file mode 100644 index 0000000..246b867 --- /dev/null +++ b/test/resource/BagnardSans.svg @@ -0,0 +1,226 @@ + + + + +Created by FontForge 20200511 at Sat Apr 25 16:28:08 2015 +SIL Open Font License OFL +https://open-foundry.com/fonts/bagnard_sans_regular +https://github.com/dconstruct/Bagnard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +