Repository: jiro4989/textimg Branch: master Commit: 88be915c4918 Files: 55 Total size: 164.8 KB Directory structure: gitextract_1f4t7hds/ ├── .chglog/ │ ├── CHANGELOG.tpl.md │ └── config.yml ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ ├── pr-labeler.yml │ └── workflows/ │ ├── auto_merge.yml │ ├── pr-labeler.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── color/ │ └── color.go ├── completions/ │ ├── fish/ │ │ └── textimg.fish │ └── zsh/ │ └── _textimg ├── config/ │ ├── config.go │ ├── config_test.go │ ├── envvar.go │ ├── face.go │ ├── face_test.go │ └── writer_mock.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── image/ │ ├── encode.go │ ├── image.go │ └── util.go ├── images/ │ └── .gitkeep ├── internal/ │ └── global/ │ ├── env.go │ └── version.go ├── log/ │ ├── log.go │ └── log_test.go ├── main.go ├── main_test.go ├── parser/ │ ├── grammar.peg │ ├── grammar.peg.go │ ├── parser.go │ └── parser_test.go ├── root.go ├── root_common_test.go ├── root_on_docker_test.go ├── root_test.go ├── scripts/ │ ├── fetch_color.sh │ └── width/ │ └── main.go ├── testdata/ │ └── in/ │ ├── 255.txt │ ├── illegal_font.otc │ ├── illegal_font.ttc │ ├── illegal_font.txt │ └── red_grad.txt └── token/ ├── token.go └── token_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .chglog/CHANGELOG.tpl.md ================================================ {{ range .Versions }} ## Changes {{ range .CommitGroups -}} ### {{ .Title }} {{ range .Commits -}} * {{ .Subject }} {{ end }} {{ end -}} {{- if .RevertCommits -}} ### Reverts {{ range .RevertCommits -}} * {{ .Revert.Header }} {{ end }} {{ end -}} {{- if .MergeCommits -}} ### Pull Requests {{ range .MergeCommits -}} * {{ .Header }} {{ end }} {{ end -}} {{- if .NoteGroups -}} {{ range .NoteGroups -}} ### {{ .Title }} {{ range .Notes }} {{ .Body }} {{ end }} {{ end -}} {{ end -}} {{ end -}} ================================================ FILE: .chglog/config.yml ================================================ style: github template: CHANGELOG.tpl.md info: title: CHANGELOG repository_url: https://github.com/YOUR_NAME/REPOSITORY options: commits: filters: Type: - feat - fix - perf - refactor commit_groups: # title_maps: # feat: Features # fix: Bug Fixes # perf: Performance Improvements # refactor: Code Refactoring header: pattern: "^(\\w*)\\:\\s(.*)$" pattern_maps: - Type - Subject notes: keywords: - BREAKING CHANGE ================================================ FILE: .dockerignore ================================================ bin build .git ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: jiro4989 --- ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior A clear and concise description of what you expected to happen. ## Screenshots or Logs If applicable, add screenshots to help explain your problem. ## Environment (please complete the following information) - OS: [e.g. Windows10] - Version: [e.g. 1.6.0] ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: feature assignees: jiro4989 --- ## Is your feature request related to a problem? Please describe A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] ## Describe the solution you'd like A clear and concise description of what you want to happen. ## Describe alternatives you've considered A clear and concise description of any alternative solutions or features you've considered. ## Additional context Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates --- version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" cooldown: default-days: 7 assignees: - "jiro4989" - package-ecosystem: "docker" directory: "/" schedule: interval: "daily" cooldown: default-days: 7 assignees: - "jiro4989" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" cooldown: default-days: 7 assignees: - "jiro4989" ================================================ FILE: .github/pr-labeler.yml ================================================ feature: feature/* bug: hotfix/* chore: chore/* ================================================ FILE: .github/workflows/auto_merge.yml ================================================ --- name: Dependabot auto-merge "on": pull_request permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs if: >- ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .github/workflows/pr-labeler.yml ================================================ name: labeler on: pull_request: types: [opened] jobs: labeler: runs-on: ubuntu-latest steps: - uses: TimonVS/pr-labeler-action@f9c084306ce8b3f488a8f3ee1ccedc6da131d1af # v5.0.0 with: configuration-path: .github/pr-labeler.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ --- name: release "on": push: tags: - 'v*.*.*' env: app: textimg goversion: '1.25' build_opts: '-ldflags="-s -w -extldflags \"-static\""' description: 'textimg is command to convert from color text (ANSI or 256) to image.' jobs: build-artifact: runs-on: ubuntu-latest strategy: matrix: os: [linux, windows, darwin] arch: [amd64, arm64] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - name: Build run: | go build ${{ env.build_opts }} if [[ $GOOS = windows ]]; then 7z a ${{ env.app }}-$GOOS-$GOARCH.zip ./${{ env.app }}.exe else tar czf ${{ env.app }}-$GOOS-$GOARCH.tar.gz ./${{ env.app }} fi env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} - name: Upload artifact (windows) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: artifact-${{ matrix.os }}-${{ matrix.arch }} path: ${{ env.app }}-${{ matrix.os }}-${{ matrix.arch }}.zip if: matrix.os == 'windows' - name: Upload artifact (unix) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: artifact-${{ matrix.os }}-${{ matrix.arch }} path: ${{ env.app }}-${{ matrix.os }}-${{ matrix.arch }}.tar.gz if: matrix.os != 'windows' build-debian-packages: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - run: go build ${{ env.build_opts }} - name: Create package run: | mkdir -p .debpkg/usr/bin cp -p ./${{ env.app }} .debpkg/usr/bin/ - uses: jiro4989/build-deb-action@a883c65147d80579cb359548b9a902ff0a35ae5b # v4.3.0 with: package: ${{ env.app }} package_root: .debpkg maintainer: jiro4989 version: ${{ github.ref }} arch: 'amd64' desc: ${{ env.description }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: artifact-deb path: | ./*.deb build-rpm-packages: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - run: go build ${{ env.build_opts }} - name: Create package run: | mkdir -p .rpmpkg/usr/bin cp -p ./${{ env.app }} .rpmpkg/usr/bin/ - uses: jiro4989/build-rpm-action@f11474937f502aaa8bb36d8c1a8ec6f8de536a0c # v2.5.0 with: summary: '${{ env.app }} is command to convert from color text (ANSI or 256) to image.' package: ${{ env.app }} package_root: .rpmpkg maintainer: jiro4989 version: ${{ github.ref }} arch: 'x86_64' desc: ${{ env.description }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: artifact-rpm path: | ./*.rpm !./*-debuginfo-*.rpm create-release: runs-on: ubuntu-latest needs: - build-artifact - build-debian-packages - build-rpm-packages steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate changelog run: | wget https://github.com/git-chglog/git-chglog/releases/download/0.9.1/git-chglog_linux_amd64 chmod +x git-chglog_linux_amd64 mv git-chglog_linux_amd64 git-chglog ./git-chglog --output ./changelog $(git describe --tags $(git rev-list --tags --max-count=1)) - name: Create Release id: create-release uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1.1.4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} body_path: ./changelog draft: false prerelease: false - name: Write upload_url to file run: echo '${{ steps.create-release.outputs.upload_url }}' > upload_url.txt - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: create-release path: upload_url.txt upload-release: runs-on: ubuntu-latest needs: create-release strategy: matrix: os: [linux, windows, darwin] arch: [amd64, arm64] include: - os: windows asset_content_type: application/zip - os: linux asset_content_type: application/gzip - os: darwin asset_content_type: application/gzip steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: artifact-${{ matrix.os }}-${{ matrix.arch }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: create-release - id: vars run: | echo "::set-output name=upload_url::$(cat upload_url.txt)" echo "::set-output name=asset_name::$(ls ${{ env.app }}-${{ matrix.os }}-${{ matrix.arch }}.* | head -n 1)" - name: Upload Release Asset id: upload-release-asset uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 # v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.vars.outputs.upload_url }} asset_path: ${{ steps.vars.outputs.asset_name }} asset_name: ${{ steps.vars.outputs.asset_name }} asset_content_type: ${{ matrix.asset_content_type }} upload-linux-package: runs-on: ubuntu-latest needs: create-release strategy: matrix: include: - pkg: deb asset_content_type: application/vnd.debian.binary-package - pkg: rpm asset_content_type: application/x-rpm steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: artifact-${{ matrix.pkg }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: create-release - id: vars run: | echo "::set-output name=upload_url::$(cat upload_url.txt)" echo "::set-output name=asset_name::$(ls *.${{ matrix.pkg }} | head -n 1)" - name: Upload Release Asset id: upload-release-asset uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 # v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.vars.outputs.upload_url }} asset_path: ${{ steps.vars.outputs.asset_name }} asset_name: ${{ steps.vars.outputs.asset_name }} asset_content_type: ${{ matrix.asset_content_type }} ================================================ FILE: .github/workflows/test.yml ================================================ --- name: test "on": push: branches: - master paths-ignore: - README* - LICENSE pull_request: paths-ignore: - README* - LICENSE env: goversion: '1.25' jobs: test: runs-on: ubuntu-latest strategy: matrix: go: - '1.25' - '1.x' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ matrix.go }} - run: go build - run: go install - run: go test -cover ./... build-arm64: runs-on: ubuntu-latest strategy: matrix: go: - '1.x' os: - 'linux' - 'darwin' - 'windows' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ matrix.go }} # if use CGO_ENABLED=1 then use this code. # # - run: sudo apt-get install -y gcc-aarch64-linux-gnu # - run: GOOS=${{ matrix.os }} GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -o textimg_${{ matrix.os }}_arm64 - run: GOOS=${{ matrix.os }} GOARCH=arm64 go build -o textimg_${{ matrix.os }}_arm64 - run: gzip textimg_${{ matrix.os }}_arm64 - name: Upload artifact (windows) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: textimg_${{ matrix.os }}_arm64.gz path: textimg_${{ matrix.os }}_arm64.gz format: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - name: Check code format run: | go mod download count="$(go fmt ./... | wc -l)" if [[ "$count" -ne 0 ]]; then echo "[ERR] 'go fmt ./...' してください" >&2 exit 1 fi docker-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Tests run: | make docker-build make docker-test lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - name: Lint run: go vet . coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.goversion }} - run: go test -coverprofile=coverage.out ./... - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 ================================================ FILE: .gitignore ================================================ /bin/ /dist/ /testdata/out/ /testdata/out*/ images/* !images/.gitkeep scripts/width/width !completions/*/textimg textimg ================================================ FILE: Dockerfile ================================================ FROM golang:1.26-alpine3.22 AS base RUN go version \ && echo $GOPATH \ && apk update \ && apk add --no-cache git wget unzip fontconfig alpine-sdk bash \ && wget https://github.com/tomokuni/Myrica/raw/master/product/MyricaM.zip -q -O /tmp/MyricaM.zip \ && (cd /tmp && unzip MyricaM.zip) \ && git clone https://github.com/googlefonts/noto-emoji /usr/local/src/noto-emoji \ && wget https://www.wfonts.com/download/data/2016/04/23/symbola/symbola.zip -q -O /tmp/symbola.zip \ && (cd /tmp && unzip symbola.zip) ################################################################################ FROM base AS builder COPY . /app WORKDIR /app RUN go install ################################################################################ FROM alpine:3.23.4 AS runtime COPY --from=builder /go/bin/textimg /usr/local/bin/ COPY --from=builder /tmp/MyricaM.TTC /usr/share/fonts/truetype/myrica/MyricaM.TTC COPY --from=builder /usr/local/src/noto-emoji/png/128 /usr/share/emoji-image COPY --from=builder /tmp/Symbola_hint.ttf /usr/share/fonts/truetype/symbola/ COPY --from=builder /tmp/Symbola_hint.ttf /usr/share/fonts/truetype/ancient-scripts/ ENV TEXTIMG_OUTPUT_DIR /images ENV TEXTIMG_FONT_FILE /usr/share/fonts/truetype/myrica/MyricaM.TTC ENV TEXTIMG_EMOJI_DIR /usr/share/emoji-image ENV TEXTIMG_EMOJI_FONT_FILE /usr/share/fonts/truetype/symbola/Symbola_hint.ttf ENV LANG ja_JP.UTF-8 RUN mkdir /images ENTRYPOINT ["/usr/local/bin/textimg"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 jiro4989 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. ================================================ FILE: Makefile ================================================ textimg: parser/grammar.peg.go *.go */*.go go fmt ./... go build parser/grammar.peg.go: parser/grammar.peg peg parser/grammar.peg .PHONY: help help: ## ドキュメントのヘルプを表示する。 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: test test: textimg ## テストコードを実行する go test -cover ./... .PHONY: docker-build docker-build: ## Dockerイメージをビルドする docker compose build .PHONY: docker-test docker-test: ## Docker環境でgo testを実行する docker compose run --rm base go test -tags docker -cover ./... .PHONY: docker-push docker-push: ## DockerHubにイメージをPushする docker push jiro4989/textimg .PHONY: setup-tools setup-tools: ## 開発時に使うツールをインストールする go install github.com/pointlander/peg@latest wget https://raw.githubusercontent.com/ekalinin/github-markdown-toc/master/gh-md-toc sudo install -m 0755 ./gh-md-toc /usr/local/bin/ -rm -f gh-md-toc ================================================ FILE: README.md ================================================ # textimg ![test](https://github.com/jiro4989/textimg/workflows/test/badge.svg) [![codecov](https://codecov.io/gh/jiro4989/textimg/branch/master/graph/badge.svg)](https://codecov.io/gh/jiro4989/textimg) textimg is command to convert from color text (ANSI or 256) to image. Drawn image keeps having colors of escape sequence. * [README on Japanese](./README_ja.md) Table of contents: * [textimg](#textimg) * [Usage](#usage) * [Simple examples](#simple-examples) * [With other commands](#with-other-commands) * [Rainbow examples](#rainbow-examples) * [From ANSI color](#from-ansi-color) * [From 256 color](#from-256-color) * [From 256 RGB color](#from-256-rgb-color) * [Animation GIF](#animation-gif) * [Slide animation GIF](#slide-animation-gif) * [Using on Docker](#using-on-docker) * [Saving shortcut](#saving-shortcut) * [Installation](#installation) * [Linux users (Debian base distros)](#linux-users-debian-base-distros) * [Linux users (RHEL compatible distros)](#linux-users-rhel-compatible-distros) * [With Go](#with-go) * [Manual](#manual) * [Help](#help) * [Fonts](#fonts) * [Default font path](#default-font-path) * [Emoji font (image file path)](#emoji-font-image-file-path) * [Emoji font (TTF)](#emoji-font-ttf) * [Tab Completions](#tab-completions) * [Bash](#bash) * [Zsh](#zsh) * [Fish](#fish) * [Development](#development) * [How to build](#how-to-build) * [How to test](#how-to-test) * [See also](#see-also) ## Usage ### Simple examples ```bash textimg $'\x1b[31mRED\x1b[0m' > out.png textimg $'\x1b[31mRED\x1b[0m' -o out.png echo -e '\x1b[31mRED\x1b[0m' | textimg -o out.png echo -e '\x1b[31mRED\x1b[0m' | textimg --background 0,255,255,255 -o out.jpg echo -e '\x1b[31mRED\x1b[0m' | textimg --background black -o out.gif ``` Output image format is PNG or JPG or GIF. File extension of `-o` option defines output image format. Default image format is PNG. if you write image file with `>` redirect then image file will be saved as PNG file. ### With other commands grep: ```bash echo hello world | grep hello --color=always | textimg -o out.png ``` ![image](https://user-images.githubusercontent.com/13825004/92329722-4e77d380-f0a4-11ea-97eb-0de316ebf6c7.png) screenfetch: ```bash screenfetch | textimg -o out.png ``` [bat](https://github.com/sharkdp/bat): ```bash bat --color=always /etc/profile | textimg -o out.png ``` ![image](https://user-images.githubusercontent.com/13825004/92329806-03aa8b80-f0a5-11ea-95f4-d876c34d65d6.png) ccze: ```bash ls -lah | ccze -A | textimg -o out.png ``` ![image](https://user-images.githubusercontent.com/13825004/113440487-7e633b80-9427-11eb-8e03-4888308780a7.png) lolcat: ```bash seq -f 'seq %g | xargs' 18 | bash | lolcat -f --freq=0.5 | textimg -o out.png ``` ![image](https://user-images.githubusercontent.com/13825004/113440659-ce420280-9427-11eb-933b-7f9b1b618264.png) ### Rainbow examples #### From ANSI color textimg supports `\x1b[30m` notation. ```bash colors=(30 31 32 33 34 35 36 37) i=0 while read -r line; do echo -e "$line" | sed -r 's/.*/\x1b['"${colors[$((i%8))]}"'m&\x1b[m/g' i=$((i+1)) done <<< "$(seq 8 | xargs -I@ echo TEST)" | textimg -b 50,100,12,255 -o testdata/out/rainbow.png ``` Output is here. ![Rainbow example](docs/rainbow.png) #### From 256 color textimg supports `\x1b[38;5;255m` notation. Foreground example is below. ```bash seq 0 255 | while read -r i; do echo -ne "\x1b[38;5;${i}m$(printf %03d $i)" if [ $(((i+1) % 16)) -eq 0 ]; then echo fi done | textimg -o 256_fg.png ``` Output is here. ![256 foreground example](docs/256_fg.png) Background example is below. ```bash seq 0 255 | while read -r i; do echo -ne "\x1b[48;5;${i}m$(printf %03d $i)" if [ $(((i+1) % 16)) -eq 0 ]; then echo fi done | textimg -o 256_bg.png ``` Output is here. ![256 background example](docs/256_bg.png) #### From 256 RGB color textimg supports `\x1b[38;2;255;0;0m` notation. ```bash seq 0 255 | while read i; do echo -ne "\x1b[38;2;${i};0;0m$(printf %03d $i)" if [ $(((i+1) % 16)) -eq 0 ]; then echo fi done | textimg -o extrgb_f_gradation.png ``` Output is here. ![RGB gradation example](docs/extrgb_f_gradation.png) #### Animation GIF textimg supports animation GIF. ```bash echo -e '\x1b[31mText\x1b[0m \x1b[32mText\x1b[0m \x1b[33mText\x1b[0m \x1b[34mText\x1b[0m \x1b[35mText\x1b[0m \x1b[36mText\x1b[0m \x1b[37mText\x1b[0m \x1b[41mText\x1b[0m \x1b[42mText\x1b[0m \x1b[43mText\x1b[0m \x1b[44mText\x1b[0m \x1b[45mText\x1b[0m \x1b[46mText\x1b[0m \x1b[47mText\x1b[0m' | textimg -a -o ansi_fb_anime_1line.gif ``` Output is here. ![Animation GIF example](docs/ansi_fb_anime_1line.gif) #### Slide animation GIF ```bash echo -e '\x1b[31mText\x1b[0m \x1b[32mText\x1b[0m \x1b[33mText\x1b[0m \x1b[34mText\x1b[0m \x1b[35mText\x1b[0m \x1b[36mText\x1b[0m \x1b[37mText\x1b[0m \x1b[41mText\x1b[0m \x1b[42mText\x1b[0m \x1b[43mText\x1b[0m \x1b[44mText\x1b[0m \x1b[45mText\x1b[0m \x1b[46mText\x1b[0m \x1b[47mText\x1b[0m' | textimg -l 5 -SE -o slide_5_1_rainbow_forever.gif ``` Output is here. ![Slide Animation GIF example](docs/slide_5_1_rainbow_forever.gif) ### Using on Docker You can use textimg on Docker. ([DockerHub](https://hub.docker.com/r/jiro4989/textimg)) ```bash docker pull jiro4989/textimg docker run -v $(pwd):/images -it jiro4989/textimg -h docker run -v $(pwd):/images -it jiro4989/textimg Testあいうえお😄 -o /images/a.png docker run -v $(pwd):/images -it jiro4989/textimg Testあいうえお😄 -s ``` or build docker image of local Dockerfile. ```bash docker-compose build docker-compose run textimg $'\x1b[31mHello\x1b[42mWorld\x1b[m' -s ``` ### Saving shortcut `textimg` saves an image as `t.png` to `$HOME/Pictures` (`%USERPROFILE%` on Windows) with `-s` options. You can change this directory with `TEXTIMG_OUTPUT_DIR` environment variables. `textimg` adds current timestamp to the file suffix when activate `-t` options. ```bash $ textimg 寿司 -st $ ls ~/Pictures/ t_2021-03-21-194959.png ``` And, `textimg` adds number to the file suffix when activate `-n` options and the file has existed. ```bash $ textimg 寿司 -sn $ textimg 寿司 -sn $ ls ~/Pictures/ t.png t_2.png ``` ## Installation ### Linux users (Debian base distros) ```bash wget https://github.com/jiro4989/textimg/releases/download/v3.1.9/textimg_3.1.9_amd64.deb sudo dpkg -i ./*.deb ``` ### Linux users (RHEL compatible distros) ```bash sudo yum install https://github.com/jiro4989/textimg/releases/download/v3.1.9/textimg-3.1.9-1.el7.x86_64.rpm ``` ### With Go ```bash go install github.com/jiro4989/textimg/v3@latest ``` ### Manual Download binary from [Releases](https://github.com/jiro4989/textimg/releases). ## Help ``` textimg is command to convert from colored text (ANSI or 256) to image. Usage: textimg [flags] Examples: textimg $'\x1b[31mRED\x1b[0m' -o out.png Flags: -g, --foreground string foreground text color. available color types are [black|red|green|yellow|blue|magenta|cyan|white] or (R,G,B,A(0~255)) (default "white") -b, --background string background text color. color types are same as "foreground" option (default "black") -f, --fontfile string font file path. You can change this default value with environment variables TEXTIMG_FONT_FILE -x, --fontindex int -e, --emoji-fontfile string emoji font file -X, --emoji-fontindex int -i, --use-emoji-font use emoji font -z, --shellgei-emoji-fontfile emoji font file for shellgei-bot (path: "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf") -F, --fontsize int font size (default 20) -o, --out string output image file path. available image formats are [png | jpg | gif] -t, --timestamp add time stamp to output image file path. -n, --numbered add number-suffix to filename when the output file was existed. ex: t_2.png -s, --shellgei-imagedir image directory path for shellgei-bot (path: "/images/t.png") -a, --animation generate animation gif -d, --delay int animation delay time (default 20) -l, --line-count int animation input line count (default 1) -S, --slide use slide animation -W, --slide-width int sliding animation width (default 1) -E, --forever sliding forever --environments print environment variables --slack resize to slack icon size (128x128 px) -h, --help help for textimg -v, --version version for textimg ``` ## Fonts ### Default font path Default fonts that to use are below. |OS |Font path | |-------|----------| |Linux |/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc | |Linux |/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc | |MacOS |/System/Library/Fonts/AppleSDGothicNeo.ttc | |iOS |/System/Library/Fonts/Core/AppleSDGothicNeo.ttc | |Android|/system/fonts/NotoSansCJK-Regular.ttc | |Windows|C:\Windows\Fonts\msgothic.ttc | You can change this font path with environment variables `TEXTIMG_FONT_FILE` . Examples. ```bash export TEXTIMG_FONT_FILE=/usr/share/fonts/TTF/HackGen-Regular.ttf ``` ### Emoji font (image file path) textimg needs emoji image files to draw emoji. You have to set `TEXTIMG_EMOJI_DIR` environment variables if you want to draw one. For example, run below. ```bash # You can clone your favorite fonts here. sudo git clone https://github.com/googlefonts/noto-emoji /usr/local/src/noto-emoji export TEXTIMG_EMOJI_DIR=/usr/local/src/noto-emoji/png/128 export LANG=ja_JP.UTF-8 echo Test👍 | textimg -o emoji.png ``` ![Emoji example](docs/emoji.png) ### Emoji font (TTF) textimg can change emoji font with `TEXTIMG_EMOJI_FONT_FILE` environment variables and set `-i` option. For example, switching emoji font to [Symbola font](https://www.wfonts.com/font/symbola). ```bash export TEXTIMG_EMOJI_FONT_FILE=/usr/share/fonts/TTF/Symbola.ttf echo あ😃a👍!👀ん👄 | textimg -i -o emoji_symbola.png ``` ![Symbola emoji example](docs/emoji_symbola.png) ## Tab Completions You can use TAB completions on your shell. ### Bash Run below. ```bash sudo cp -p completions/bash/textimg /usr/share/bash-completion/completions/textimg ``` ### Zsh Run below. ```bash sudo cp -p completions/zsh/textimg /usr/share/zsh/functions/Completion/_textimg # or # sudo cp -p completions/zsh/textimg {path to your $fpath} ``` ### Fish Run below. ```bash ln -sfn completions/fish/textimg.fish $HOME/.config/fish/completions/textimg.fish ``` ## Development go version go1.17 linux/amd64 ### How to build You run below. ```bash make setup-tools make ``` **I didn't test on Windows.** ### How to test ```bash make test # docker make docker-build make docker-test ``` ## See also * ================================================ FILE: color/color.go ================================================ package color import ( c "image/color" ) type RGBA c.RGBA var ( RGBABlack = RGBA{0, 0, 0, 255} RGBARed = RGBA{255, 0, 0, 255} RGBAGreen = RGBA{0, 255, 0, 255} RGBAYellow = RGBA{255, 255, 0, 255} RGBABlue = RGBA{0, 0, 255, 255} RGBAMagenta = RGBA{255, 0, 255, 255} RGBACyan = RGBA{0, 255, 255, 255} RGBALightGray = RGBA{211, 211, 211, 255} RGBADarkGray = RGBA{169, 169, 169, 255} RGBALightRed = RGBA{255, 144, 144, 255} RGBALightGreen = RGBA{144, 238, 144, 255} RGBALightYellow = RGBA{255, 255, 224, 255} RGBALightBlue = RGBA{173, 216, 230, 255} RGBALightMagenta = RGBA{255, 224, 255, 255} RGBALightCyan = RGBA{224, 255, 255, 255} RGBAWhite = RGBA{255, 255, 255, 255} StringMap = map[string]RGBA{ "black": RGBABlack, "red": RGBARed, "green": RGBAGreen, "yellow": RGBAYellow, "blue": RGBABlue, "magenta": RGBAMagenta, "cyan": RGBACyan, "white": RGBAWhite, } // \x1b[NNm とかの NN に紐づくRGBA色 // 例: \x1b[30m ANSIMap = map[int]RGBA{ // 文字色 30: RGBABlack, 31: RGBARed, 32: RGBAGreen, 33: RGBAYellow, 34: RGBABlue, 35: RGBAMagenta, 36: RGBACyan, 37: RGBALightGray, 90: RGBADarkGray, 91: RGBALightRed, 92: RGBALightGreen, 93: RGBALightYellow, 94: RGBALightBlue, 95: RGBALightMagenta, 96: RGBALightCyan, 97: RGBAWhite, // 背景色 40: RGBABlack, 41: RGBARed, 42: RGBAGreen, 43: RGBAYellow, 44: RGBABlue, 45: RGBAMagenta, 46: RGBACyan, 47: RGBALightGray, 100: RGBADarkGray, 101: RGBALightRed, 102: RGBALightGreen, 103: RGBALightYellow, 104: RGBALightBlue, 105: RGBALightMagenta, 106: RGBALightCyan, 107: RGBAWhite, } // \x1b[38;5;NNNm とかの NNN に紐づくRGBA色 // 例: \x1b[38;5;114m Map256 = map[int]RGBA{ 0: {0, 0, 0, 255}, 1: {128, 0, 0, 255}, 2: {0, 128, 0, 255}, 3: {128, 128, 0, 255}, 4: {0, 0, 128, 255}, 5: {128, 0, 128, 255}, 6: {0, 128, 128, 255}, 7: {192, 192, 192, 255}, 8: {128, 128, 128, 255}, 9: {255, 0, 0, 255}, 10: {0, 255, 0, 255}, 11: {255, 255, 0, 255}, 12: {0, 0, 255, 255}, 13: {255, 0, 255, 255}, 14: {0, 255, 255, 255}, 15: {255, 255, 255, 255}, 16: {0, 0, 0, 255}, 17: {0, 0, 95, 255}, 18: {0, 0, 135, 255}, 19: {0, 0, 175, 255}, 20: {0, 0, 215, 255}, 21: {0, 0, 255, 255}, 22: {0, 95, 0, 255}, 23: {0, 95, 95, 255}, 24: {0, 95, 135, 255}, 25: {0, 95, 175, 255}, 26: {0, 95, 215, 255}, 27: {0, 95, 255, 255}, 28: {0, 135, 0, 255}, 29: {0, 135, 95, 255}, 30: {0, 135, 135, 255}, 31: {0, 135, 175, 255}, 32: {0, 135, 215, 255}, 33: {0, 135, 255, 255}, 34: {0, 175, 0, 255}, 35: {0, 175, 95, 255}, 36: {0, 175, 135, 255}, 37: {0, 175, 175, 255}, 38: {0, 175, 215, 255}, 39: {0, 175, 255, 255}, 40: {0, 215, 0, 255}, 41: {0, 215, 95, 255}, 42: {0, 215, 135, 255}, 43: {0, 215, 175, 255}, 44: {0, 215, 215, 255}, 45: {0, 215, 255, 255}, 46: {0, 255, 0, 255}, 47: {0, 255, 95, 255}, 48: {0, 255, 135, 255}, 49: {0, 255, 175, 255}, 50: {0, 255, 215, 255}, 51: {0, 255, 255, 255}, 52: {95, 0, 0, 255}, 53: {95, 0, 95, 255}, 54: {95, 0, 135, 255}, 55: {95, 0, 175, 255}, 56: {95, 0, 215, 255}, 57: {95, 0, 255, 255}, 58: {95, 95, 0, 255}, 59: {95, 95, 95, 255}, 60: {95, 95, 135, 255}, 61: {95, 95, 175, 255}, 62: {95, 95, 215, 255}, 63: {95, 95, 255, 255}, 64: {95, 135, 0, 255}, 65: {95, 135, 95, 255}, 66: {95, 135, 135, 255}, 67: {95, 135, 175, 255}, 68: {95, 135, 215, 255}, 69: {95, 135, 255, 255}, 70: {95, 175, 0, 255}, 71: {95, 175, 95, 255}, 72: {95, 175, 135, 255}, 73: {95, 175, 175, 255}, 74: {95, 175, 215, 255}, 75: {95, 175, 255, 255}, 76: {95, 215, 0, 255}, 77: {95, 215, 95, 255}, 78: {95, 215, 135, 255}, 79: {95, 215, 175, 255}, 80: {95, 215, 215, 255}, 81: {95, 215, 255, 255}, 82: {95, 255, 0, 255}, 83: {95, 255, 95, 255}, 84: {95, 255, 135, 255}, 85: {95, 255, 175, 255}, 86: {95, 255, 215, 255}, 87: {95, 255, 255, 255}, 88: {135, 0, 0, 255}, 89: {135, 0, 95, 255}, 90: {135, 0, 135, 255}, 91: {135, 0, 175, 255}, 92: {135, 0, 215, 255}, 93: {135, 0, 255, 255}, 94: {135, 95, 0, 255}, 95: {135, 95, 95, 255}, 96: {135, 95, 135, 255}, 97: {135, 95, 175, 255}, 98: {135, 95, 215, 255}, 99: {135, 95, 255, 255}, 100: {135, 135, 0, 255}, 101: {135, 135, 95, 255}, 102: {135, 135, 135, 255}, 103: {135, 135, 175, 255}, 104: {135, 135, 215, 255}, 105: {135, 135, 255, 255}, 106: {135, 175, 0, 255}, 107: {135, 175, 95, 255}, 108: {135, 175, 135, 255}, 109: {135, 175, 175, 255}, 110: {135, 175, 215, 255}, 111: {135, 175, 255, 255}, 112: {135, 215, 0, 255}, 113: {135, 215, 95, 255}, 114: {135, 215, 135, 255}, 115: {135, 215, 175, 255}, 116: {135, 215, 215, 255}, 117: {135, 215, 255, 255}, 118: {135, 255, 0, 255}, 119: {135, 255, 95, 255}, 120: {135, 255, 135, 255}, 121: {135, 255, 175, 255}, 122: {135, 255, 215, 255}, 123: {135, 255, 255, 255}, 124: {175, 0, 0, 255}, 125: {175, 0, 95, 255}, 126: {175, 0, 135, 255}, 127: {175, 0, 175, 255}, 128: {175, 0, 215, 255}, 129: {175, 0, 255, 255}, 130: {175, 95, 0, 255}, 131: {175, 95, 95, 255}, 132: {175, 95, 135, 255}, 133: {175, 95, 175, 255}, 134: {175, 95, 215, 255}, 135: {175, 95, 255, 255}, 136: {175, 135, 0, 255}, 137: {175, 135, 95, 255}, 138: {175, 135, 135, 255}, 139: {175, 135, 175, 255}, 140: {175, 135, 215, 255}, 141: {175, 135, 255, 255}, 142: {175, 175, 0, 255}, 143: {175, 175, 95, 255}, 144: {175, 175, 135, 255}, 145: {175, 175, 175, 255}, 146: {175, 175, 215, 255}, 147: {175, 175, 255, 255}, 148: {175, 215, 0, 255}, 149: {175, 215, 95, 255}, 150: {175, 215, 135, 255}, 151: {175, 215, 175, 255}, 152: {175, 215, 215, 255}, 153: {175, 215, 255, 255}, 154: {175, 255, 0, 255}, 155: {175, 255, 95, 255}, 156: {175, 255, 135, 255}, 157: {175, 255, 175, 255}, 158: {175, 255, 215, 255}, 159: {175, 255, 255, 255}, 160: {215, 0, 0, 255}, 161: {215, 0, 95, 255}, 162: {215, 0, 135, 255}, 163: {215, 0, 175, 255}, 164: {215, 0, 215, 255}, 165: {215, 0, 255, 255}, 166: {215, 95, 0, 255}, 167: {215, 95, 95, 255}, 168: {215, 95, 135, 255}, 169: {215, 95, 175, 255}, 170: {215, 95, 215, 255}, 171: {215, 95, 255, 255}, 172: {215, 135, 0, 255}, 173: {215, 135, 95, 255}, 174: {215, 135, 135, 255}, 175: {215, 135, 175, 255}, 176: {215, 135, 215, 255}, 177: {215, 135, 255, 255}, 178: {215, 175, 0, 255}, 179: {215, 175, 95, 255}, 180: {215, 175, 135, 255}, 181: {215, 175, 175, 255}, 182: {215, 175, 215, 255}, 183: {215, 175, 255, 255}, 184: {215, 215, 0, 255}, 185: {215, 215, 95, 255}, 186: {215, 215, 135, 255}, 187: {215, 215, 175, 255}, 188: {215, 215, 215, 255}, 189: {215, 215, 255, 255}, 190: {215, 255, 0, 255}, 191: {215, 255, 95, 255}, 192: {215, 255, 135, 255}, 193: {215, 255, 175, 255}, 194: {215, 255, 215, 255}, 195: {215, 255, 255, 255}, 196: {255, 0, 0, 255}, 197: {255, 0, 95, 255}, 198: {255, 0, 135, 255}, 199: {255, 0, 175, 255}, 200: {255, 0, 215, 255}, 201: {255, 0, 255, 255}, 202: {255, 95, 0, 255}, 203: {255, 95, 95, 255}, 204: {255, 95, 135, 255}, 205: {255, 95, 175, 255}, 206: {255, 95, 215, 255}, 207: {255, 95, 255, 255}, 208: {255, 135, 0, 255}, 209: {255, 135, 95, 255}, 210: {255, 135, 135, 255}, 211: {255, 135, 175, 255}, 212: {255, 135, 215, 255}, 213: {255, 135, 255, 255}, 214: {255, 175, 0, 255}, 215: {255, 175, 95, 255}, 216: {255, 175, 135, 255}, 217: {255, 175, 175, 255}, 218: {255, 175, 215, 255}, 219: {255, 175, 255, 255}, 220: {255, 215, 0, 255}, 221: {255, 215, 95, 255}, 222: {255, 215, 135, 255}, 223: {255, 215, 175, 255}, 224: {255, 215, 215, 255}, 225: {255, 215, 255, 255}, 226: {255, 255, 0, 255}, 227: {255, 255, 95, 255}, 228: {255, 255, 135, 255}, 229: {255, 255, 175, 255}, 230: {255, 255, 215, 255}, 231: {255, 255, 255, 255}, 232: {8, 8, 8, 255}, 233: {18, 18, 18, 255}, 234: {28, 28, 28, 255}, 235: {38, 38, 38, 255}, 236: {48, 48, 48, 255}, 237: {58, 58, 58, 255}, 238: {68, 68, 68, 255}, 239: {78, 78, 78, 255}, 240: {88, 88, 88, 255}, 241: {98, 98, 98, 255}, 242: {108, 108, 108, 255}, 243: {118, 118, 118, 255}, 244: {128, 128, 128, 255}, 245: {138, 138, 138, 255}, 246: {148, 148, 148, 255}, 247: {158, 158, 158, 255}, 248: {168, 168, 168, 255}, 249: {178, 178, 178, 255}, 250: {188, 188, 188, 255}, 251: {198, 198, 198, 255}, 252: {208, 208, 208, 255}, 253: {218, 218, 218, 255}, 254: {228, 228, 228, 255}, 255: {238, 238, 238, 255}, } ) ================================================ FILE: completions/fish/textimg.fish ================================================ complete -c textimg -x complete -c textimg -s g -l foreground -a 'black red green yellow blue magenta cyan white' -d 'foreground text color.' complete -c textimg -s b -l background -a 'black red green yellow blue magenta cyan white' -d 'background text color.' complete -c textimg -s f -l fontfile -d 'font file path.' complete -c textimg -s x -l fontindex complete -c textimg -s e -l emoji-fontfile -d 'emoji font file.' complete -c textimg -s X -l emoji-fontindex complete -c textimg -s i -l use-emoji-font -d 'use emoji font' complete -c textimg -s z -l shellgei-emoji-fontfile -d 'emoji font file for shellgei-bot' complete -c textimg -s F -l fontdize complete -c textimg -s o -l out -d 'output image file path.' complete -c textimg -s t -l timestamp -d 'add time stamp to output image file path.' complete -c textimg -s n -l numbered -d 'add number-suffix to filename when the output file was existed.' complete -c textimg -s s -l shellgei-imagedir -d 'image directory path' complete -c textimg -s a -l animation -d 'generate animation gif' complete -c textimg -s d -l delay -d 'animation delay time (default 20)' complete -c textimg -s l -l line-count -d 'animation input line count (default 1)' complete -c textimg -s S -l slide -d 'use slide animation' complete -c textimg -s W -l slide-width -d 'sliding animation width (default 1)' complete -c textimg -s E -l forever -d 'sliding forever' complete -c textimg -l environments -d 'print environment variables' complete -c textimg -l slack -d 'resize to slack icon size (128x128 px)' complete -c textimg -s h -l help -d 'help for textimg' complete -c textimg -s v -l version -d 'version for textimg' ================================================ FILE: completions/zsh/_textimg ================================================ #compdef textimg _textimg() { _arguments \ {-g,--foreground}'[foreground text color]: :->color' \ {-b,--background}'[background text color]: :->color' \ {-f,--fontfile}'[font file path]: :->etc' \ {-x,--fontindex}': :->etc' \ {-e,--emoji-fontfile}'[emoji font file]: :->etc' \ {-X,--emoji-fontindex}': :->etc' \ {-i,--use-emoji-font}': :->etc' \ {-z,--shellgei-emoji-fontfile}'[emoji font file for shellgei-bot]: :->etc' \ {-F,--fontsize}'[font size (default 20)]: :->etc' \ {-o,--out}'[output image file path. available image formats are png or jpg or gif]: :->etc' \ {-t,--timestamp}'[add time stamp to output image file path.]: :->etc' \ {-n,--numbered}'[add number-suffix to filename when the output file was existed.]: :->etc' \ {-s,--shellgei-imagedir}'[image directory path]: :->etc' \ {-a,--animation}'[generate animation gif]: :->etc' \ {-d,--delay}'[animation delay time (default 20)]: :->etc' \ {-l,--line-count}'[animation input line count (default 1)]: :->etc' \ {-S,--slide}'[use slide animation]: :->etc' \ {-W,--slide-width}'[sliding animation width (default 1)]: :->etc' \ {-E,--forever}'[sliding forever]: :->etc' \ --environments'[print environment variables]: :->etc' \ --slack'[resize toslack icon size (128x128x px)]: :->etc' \ {-h,--help}'[help for textimg]: :->etc' \ {-v,--version}'[version for textimg]: :->etc' case "$state" in color) _values \ 'color' \ black red green yellow blue magenta cyan white ;; etc) # nothing to do ;; esac } compdef _textimg textimg # vim: ft=zsh ================================================ FILE: config/config.go ================================================ package config import ( "bufio" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" "github.com/jiro4989/textimg/v3/color" "github.com/jiro4989/textimg/v3/log" "golang.org/x/image/font" "golang.org/x/term" ) type Config struct { Foreground string // 文字色 Background string // 背景色 Outpath string // 画像の出力ファイルパス AddTimeStamp bool // ファイル名末尾にタイムスタンプ付与 SaveNumberedFile bool // 保存しようとしたファイルがすでに存在する場合に連番を付与する FontFile string // フォントファイルのパス FontIndex int // フォントコレクションのインデックス EmojiFontFile string // 絵文字用のフォントファイルのパス EmojiFontIndex int // 絵文字用のフォントコレクションのインデックス UseEmojiFont bool // 絵文字TTFを使う FontSize int // フォントサイズ UseAnimation bool // アニメーションGIFを生成する Delay int // アニメーションのディレイ時間 LineCount int // 入力データのうち何行を1フレーム画像に使うか UseSlideAnimation bool // スライドアニメーションする SlideWidth int // スライドする幅 SlideForever bool // スライドを無限にスライドするように描画する ToSlackIcon bool // Slackのアイコンサイズにする PrintEnvironments bool UseShellgeiImagedir bool UseShellgeiEmojiFontfile bool ResizeWidth int // 画像の横幅 ResizeHeight int // 画像の縦幅 ForegroundColor color.RGBA // 文字色 BackgroundColor color.RGBA // 背景色 Texts []string FileExtension string Writer io.WriteCloser FontFace font.Face EmojiFontFace font.Face EmojiDir string } type osDefaultFont struct { fontFile string fontIndex int isLinux bool } const ShellgeiEmojiFontPath = "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf" const ( defaultWindowsFont = `C:\Windows\Fonts\msgothic.ttc` defaultDarwinFont = "/System/Library/Fonts/AppleSDGothicNeo.ttc" defaultIOSFont = "/System/Library/Fonts/Core/AppleSDGothicNeo.ttc" defaultAndroidFont = "/system/fonts/NotoSansCJK-Regular.ttc" defaultLinuxFont1 = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" defaultLinuxFont2 = "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" ) // adjust はパラメータを調整する。 // 副作用を持つ。 func (a *Config) Adjust(args []string, ev EnvVars) error { a.EmojiDir = ev.EmojiDir // シェル芸イメージディレクトリの指定がある時はパスを変更する if a.UseShellgeiImagedir { var err error outDir := ev.OutputDir a.Outpath, err = outputImageDir(outDir, a.UseAnimation) if err != nil { return err } } a.addTimeStampToOutPath(time.Now()) a.addNumberSuffixToOutPath() if a.UseShellgeiEmojiFontfile { a.EmojiFontFile = ShellgeiEmojiFontPath a.UseEmojiFont = true } if a.UseSlideAnimation { a.UseAnimation = true } var err error a.ForegroundColor, err = optionColorStringToRGBA(a.Foreground) if err != nil { return err } a.BackgroundColor, err = optionColorStringToRGBA(a.Background) if err != nil { return err } // 引数にテキストの指定がなければ標準入力を使用する a.Texts = readInputText(args) // textsが空のときは警告メッセージを出力して異常終了 if err := validateInputText(a.Texts); err != nil { return err } // スライドアニメーションを使うときはテキストを加工する if a.UseSlideAnimation { a.Texts = toSlideStrings(a.Texts, a.LineCount, a.SlideWidth, a.SlideForever) } // 拡張子のみ取得 a.FileExtension = filepath.Ext(strings.ToLower(a.Outpath)) if err := a.setWriter(); err != nil { return err } if err := validateFileExtension(a.FileExtension); err != nil { return err } a.Texts = normalizeTexts(a.Texts) a.FontFace, err = readFace(a.FontFile, a.FontIndex, float64(a.FontSize)) if err != nil { return err } if a.EmojiFontFile != "" { a.EmojiFontFace, err = readFace(a.EmojiFontFile, a.EmojiFontIndex, float64(a.FontSize)) if err != nil { return err } } if a.ToSlackIcon { a.ResizeWidth = 128 a.ResizeHeight = 128 } return nil } func (a *Config) SetFontFileAndFontIndex(runtimeOS string) { if a.FontFile != "" { return } m := map[string]osDefaultFont{ "linux": { isLinux: true, }, "windows": { fontFile: defaultWindowsFont, fontIndex: 0, }, "darwin": { fontFile: defaultDarwinFont, fontIndex: 0, }, "ios": { fontFile: defaultIOSFont, fontIndex: 0, }, "android": { fontFile: defaultAndroidFont, fontIndex: 5, }, } if f, ok := m[runtimeOS]; ok { // linux だけ特殊なので特別に分岐 if !f.isLinux { a.FontFile = f.fontFile a.FontIndex = f.fontIndex return } if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { a.FontFile = "/mnt/c/Windows/Fonts/msgothic.ttc" a.FontIndex = 0 return } a.FontFile = defaultLinuxFont1 if _, err := os.Stat(a.FontFile); err != nil { a.FontFile = defaultLinuxFont2 } a.FontIndex = 5 return } } // addTimeStampToOutPath はOutpathに指定日時のタイムスタンプを付与する。 func (a *Config) addTimeStampToOutPath(t time.Time) { if !a.AddTimeStamp { return } ext := filepath.Ext(a.Outpath) file := strings.TrimSuffix(a.Outpath, ext) timestamp := t.Format("2006-01-02-150405") a.Outpath = file + "_" + timestamp + ext } // addTimeStampToOutPath はOutpathに指定日時のタイムスタンプを付与する。 func (a *Config) addNumberSuffixToOutPath() { if !a.SaveNumberedFile { return } // ファイルが存在しない時は何もしない // NOTE: 並列に実行されるとチェックしきれない場合があるけれど許容する if _, err := os.Stat(a.Outpath); err != nil { return } fileExt := filepath.Ext(a.Outpath) fileName := strings.TrimSuffix(a.Outpath, fileExt) i := 2 for { a.Outpath = fmt.Sprintf("%s_%d%s", fileName, i, fileExt) _, err := os.Stat(a.Outpath) if err != nil { return } i++ } } func (a *Config) setWriter() error { if a.Outpath == "" { // 出力先画像の指定がなく、且つ出力先がパイプならstdout + PNG/GIFと // して出力。なければそもそも画像処理しても意味が無いので終了 fd := os.Stdout.Fd() if term.IsTerminal(int(fd)) { log.Error("image data not written to a terminal. use -o, -s, pipe or redirect.") log.Error("for help, type: textimg -h") return fmt.Errorf("no output target error") } a.Writer = os.Stdout if a.UseAnimation { a.FileExtension = ".gif" } else { a.FileExtension = ".png" } return nil } if a.Writer != nil { return nil } var err error a.Writer, err = os.Create(a.Outpath) if err != nil { return err } // NOTE: writerは呼び出し元でクローズする return nil } func validateInputText(texts []string) error { var emptyCount int for _, v := range texts { if len(v) < 1 { emptyCount++ } } if emptyCount == len(texts) { err := fmt.Errorf("must need input texts.") return err } return nil } // validateFileExtension はファイル拡張子をチェックする。 func validateFileExtension(ext string) error { switch ext { case ".png", ".jpg", ".jpeg", ".gif": // 何もしない default: err := fmt.Errorf("%s is not supported extension.", ext) return err } return nil } // normalizeTexts はテキストを正規化する。 func normalizeTexts(texts []string) []string { result := texts // タブ文字は画像描画時に表示されないので暫定対応で半角スペースに置換 for i, text := range result { result[i] = strings.Replace(text, "\t", " ", -1) } // ゼロ幅文字を削除 for i, text := range result { result[i] = removeZeroWidthCharacters(text) } return result } func readInputText(args []string) []string { var texts []string if len(args) < 1 { texts = readStdin() } else { for _, v := range args { texts = append(texts, strings.Split(v, "\n")...) } } return texts } // outputImageDir は `-s` オプションで保存するさきのディレクトリパスを返す。 func outputImageDir(outDir string, useAnimation bool) (string, error) { if outDir == "" { homeDir, err := os.UserHomeDir() if err != nil { return "", err } outDir = filepath.Join(homeDir, "Pictures") } if useAnimation { return filepath.Join(outDir, "t.gif"), nil } return filepath.Join(outDir, "t.png"), nil } // オプション引数のbackgroundは2つの書き方を許容する。 // 1. black といった色の直接指定 // 2. RGBAのカンマ区切り指定 // 書式: R,G,B,A // 赤色の例: 255,0,0,255 func optionColorStringToRGBA(colstr string) (color.RGBA, error) { // "black"といった色名称でマッチするものがあれば返す colstr = strings.ToLower(colstr) col := color.StringMap[colstr] zeroColor := color.RGBA{} if col != zeroColor { return col, nil } // カンマ区切りでの指定があれば返す rgba := strings.Split(colstr, ",") if len(rgba) != 4 { return zeroColor, fmt.Errorf("illegal RGBA format: %s", colstr) } var ( r uint64 g uint64 b uint64 a uint64 err error rs = rgba[0] gs = rgba[1] bs = rgba[2] as = rgba[3] ) r, err = strconv.ParseUint(rs, 10, 8) if err != nil { return zeroColor, err } g, err = strconv.ParseUint(gs, 10, 8) if err != nil { return zeroColor, err } b, err = strconv.ParseUint(bs, 10, 8) if err != nil { return zeroColor, err } a, err = strconv.ParseUint(as, 10, 8) if err != nil { return zeroColor, err } c := color.RGBA{ R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a), } return c, nil } // toSlideStrings は文字列をスライドアニメーション用の文字列に変換する。 func toSlideStrings(src []string, lineCount, slideWidth int, slideForever bool) (ret []string) { if 1 < slideWidth { var loopCount int for i := 0; i < len(src); i += slideWidth { loopCount++ } for i := 0; i < (loopCount*slideWidth+1)-len(src); i++ { if !slideForever { src = append(src, "") } } } for i := 0; i < len(src); i += slideWidth { n := i + lineCount if len(src) < n { if slideForever { for j := i; j < n; j++ { m := j if len(src) <= m { m -= len(src) } line := src[m] ret = append(ret, line) } continue } return } // lineCountの数ずつ行を取得して戻り値に追加 for j := i; j < n; j++ { line := src[j] ret = append(ret, line) } } return } // removeZeroWidthSpace はゼロ幅文字が存在したときに削除する。 // // 参考 // * ゼロ幅スペース https://ja.wikipedia.org/wiki/%E3%82%BC%E3%83%AD%E5%B9%85%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9 func removeZeroWidthCharacters(s string) string { zwc := []rune{ 0x200b, // zero width space 0x200c, // zero width joiner 0x200d, // zero width joiner 0xfeff, // zero width no-break-space } var ret []rune chars: for _, v := range s { for _, c := range zwc { if v == c { continue chars } } ret = append(ret, v) } return string(ret) } // readStdin は標準入力を文字列の配列として返す。 func readStdin() (ret []string) { sc := bufio.NewScanner(os.Stdin) for sc.Scan() { line := sc.Text() ret = append(ret, line) } if err := sc.Err(); err != nil { panic(err) } return } ================================================ FILE: config/config_test.go ================================================ package config import ( "os" "path/filepath" "testing" "time" "github.com/jiro4989/textimg/v3/color" "github.com/stretchr/testify/assert" ) func newDefaultConfig() Config { return Config{ Foreground: "white", Background: "black", Outpath: "", AddTimeStamp: false, SaveNumberedFile: false, FontFile: "", FontIndex: 0, EmojiFontFile: "", EmojiFontIndex: 0, UseEmojiFont: false, FontSize: 20, UseAnimation: false, Delay: 20, LineCount: 1, UseSlideAnimation: false, SlideWidth: 1, SlideForever: false, ToSlackIcon: false, PrintEnvironments: false, UseShellgeiImagedir: false, UseShellgeiEmojiFontfile: false, ResizeWidth: 0, ResizeHeight: 0, Writer: NewMockWriter(false, false), } } func TestConfig_Adjust(t *testing.T) { tests := []struct { desc string config Config args []string ev EnvVars want Config wantErr bool }{ { desc: "正常系: Outpathが設定されている", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" return c }(), args: []string{"hello"}, ev: EnvVars{}, want: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" return c }(), wantErr: false, }, { desc: "正常系: UseShellgeiImagedirが有効なときはt.pngが設定される", config: func() Config { c := newDefaultConfig() c.UseShellgeiImagedir = true return c }(), args: []string{"hello"}, ev: EnvVars{ OutputDir: "sushi", }, want: func() Config { c := newDefaultConfig() c.Outpath = filepath.Join("sushi", "t.png") c.UseShellgeiImagedir = true c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" return c }(), wantErr: false, }, { desc: "正常系: UseShellgeiEmojiFontfileが有効な時は組み込みの絵文字パスが設定されて、UseEmojiFont=trueになる", config: func() Config { c := newDefaultConfig() c.UseShellgeiImagedir = true c.UseShellgeiEmojiFontfile = true return c }(), args: []string{"hello"}, ev: EnvVars{ OutputDir: "sushi", }, want: func() Config { c := newDefaultConfig() c.Outpath = filepath.Join("sushi", "t.png") c.UseShellgeiImagedir = true c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" c.UseShellgeiEmojiFontfile = true c.UseEmojiFont = true c.EmojiFontFile = ShellgeiEmojiFontPath return c }(), wantErr: false, }, { desc: "正常系: UseShellgeiImagedirが有効でUseAnimationが設定されているときはt.gifになる", config: func() Config { c := newDefaultConfig() c.UseShellgeiImagedir = true c.UseAnimation = true return c }(), args: []string{"hello"}, ev: EnvVars{ OutputDir: "sushi", }, want: func() Config { c := newDefaultConfig() c.Outpath = filepath.Join("sushi", "t.gif") c.UseShellgeiImagedir = true c.UseAnimation = true c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".gif" return c }(), wantErr: false, }, { desc: "正常系: ToSlackIconが有効なときはResizeWidthとResizeHeightが設定される", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ToSlackIcon = true return c }(), args: []string{"hello"}, ev: EnvVars{}, want: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" c.ToSlackIcon = true c.ResizeWidth = 128 c.ResizeHeight = 128 return c }(), wantErr: false, }, { desc: "正常系: UseSlideAnimationが有効なときはUseAnimationも有効になる", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.UseSlideAnimation = true return c }(), args: []string{"hello"}, ev: EnvVars{}, want: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" c.UseSlideAnimation = true c.UseAnimation = true return c }(), wantErr: false, }, { desc: "正常系: SlideWidthが2以上の時はテキストの処理が変化する", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.UseSlideAnimation = true c.LineCount = 2 c.SlideWidth = 2 c.SlideForever = true return c }(), args: []string{"hello", "hello", "hello", "hello"}, ev: EnvVars{}, want: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello", "hello", "hello", "hello"} c.FileExtension = ".png" c.UseSlideAnimation = true c.UseAnimation = true c.LineCount = 2 c.SlideWidth = 2 c.SlideForever = true return c }(), wantErr: false, }, { desc: "正常系: EmojiFontFileに存在しないファイルを指定してもエラーにはならない", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.EmojiFontFile = "sushi.otf" return c }(), args: []string{"hello"}, ev: EnvVars{}, want: func() Config { c := newDefaultConfig() c.Outpath = "t.png" c.ForegroundColor = color.RGBAWhite c.BackgroundColor = color.RGBABlack c.Texts = []string{"hello"} c.FileExtension = ".png" c.EmojiFontFile = "sushi.otf" return c }(), wantErr: false, }, { desc: "異常系: Foregroundに不正な色指定をした時はエラーを返す", config: func() Config { c := newDefaultConfig() c.Foreground = "sushi" return c }(), args: []string{"hello"}, ev: EnvVars{}, want: Config{}, wantErr: true, }, { desc: "異常系: Backgroundに不正な色指定をした時はエラーを返す", config: func() Config { c := newDefaultConfig() c.Background = "sushi" return c }(), args: []string{"hello"}, ev: EnvVars{}, want: Config{}, wantErr: true, }, { desc: "異常系: textsが空の時はエラーを返す", config: func() Config { c := newDefaultConfig() c.Outpath = "t.png" return c }(), args: []string{}, ev: EnvVars{}, want: Config{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) err := tt.config.Adjust(tt.args, tt.ev) if tt.wantErr { assert.Error(err) return } assert.NoError(err) assert.Equal(tt.want.Foreground, tt.config.Foreground) assert.Equal(tt.want.Background, tt.config.Background) assert.Equal(tt.want.Outpath, tt.config.Outpath) assert.Equal(tt.want.AddTimeStamp, tt.config.AddTimeStamp) assert.Equal(tt.want.SaveNumberedFile, tt.config.SaveNumberedFile) assert.Equal(tt.want.FontFile, tt.config.FontFile) assert.Equal(tt.want.FontIndex, tt.config.FontIndex) assert.Equal(tt.want.EmojiFontFile, tt.config.EmojiFontFile) assert.Equal(tt.want.EmojiFontIndex, tt.config.EmojiFontIndex) assert.Equal(tt.want.UseEmojiFont, tt.config.UseEmojiFont) assert.Equal(tt.want.FontSize, tt.config.FontSize) assert.Equal(tt.want.UseAnimation, tt.config.UseAnimation) assert.Equal(tt.want.Delay, tt.config.Delay) assert.Equal(tt.want.LineCount, tt.config.LineCount) assert.Equal(tt.want.UseSlideAnimation, tt.config.UseSlideAnimation) assert.Equal(tt.want.SlideWidth, tt.config.SlideWidth) assert.Equal(tt.want.SlideForever, tt.config.SlideForever) assert.Equal(tt.want.ToSlackIcon, tt.config.ToSlackIcon) assert.Equal(tt.want.PrintEnvironments, tt.config.PrintEnvironments) assert.Equal(tt.want.UseShellgeiImagedir, tt.config.UseShellgeiImagedir) assert.Equal(tt.want.UseShellgeiEmojiFontfile, tt.config.UseShellgeiEmojiFontfile) assert.Equal(tt.want.ForegroundColor, tt.config.ForegroundColor) assert.Equal(tt.want.BackgroundColor, tt.config.BackgroundColor) assert.Equal(tt.want.Texts, tt.config.Texts) assert.Equal(tt.want.FileExtension, tt.config.FileExtension) // NOTE: ここはテストするのが難しいので無視 // assert.Equal(tt.want.Writer, tt.config.Writer) // assert.Equal(tt.want.FontFace, tt.config.FontFace) // assert.Equal(tt.want.EmojiFontFace, tt.config.EmojiFontFace) assert.Equal(tt.want.EmojiDir, tt.config.EmojiDir) }) } } func TestOptionColorStringToRGBA(t *testing.T) { type TestData struct { desc string colstr string expect color.RGBA } tds := []TestData{ {desc: "BLACK", colstr: "BLACK", expect: color.RGBABlack}, {desc: "black", colstr: "black", expect: color.RGBABlack}, {desc: "red", colstr: "red", expect: color.RGBARed}, {desc: "green", colstr: "green", expect: color.RGBAGreen}, {desc: "yellow", colstr: "yellow", expect: color.RGBAYellow}, {desc: "blue", colstr: "blue", expect: color.RGBABlue}, {desc: "magenta", colstr: "magenta", expect: color.RGBAMagenta}, {desc: "cyan", colstr: "cyan", expect: color.RGBACyan}, {desc: "white", colstr: "white", expect: color.RGBAWhite}, {desc: "0,0,0,255", colstr: "0,0,0,255", expect: color.RGBA{R: 0, G: 0, B: 0, A: 255}}, {desc: "255,255,255,255", colstr: "255,255,255,255", expect: color.RGBA{R: 255, G: 255, B: 255, A: 255}}, {desc: "0,0,0,0", colstr: "0,0,0,0", expect: color.RGBA{R: 0, G: 0, B: 0, A: 0}}, } for _, v := range tds { t.Run(v.desc, func(t *testing.T) { got, err := optionColorStringToRGBA(v.colstr) assert.Nil(t, err, v.desc) assert.Equal(t, v.expect, got, v.desc) }) } // 異常系 tds = []TestData{ {desc: "不正な色文字列", colstr: "unko"}, {desc: "RGBAの書式不正(値の数不足)", colstr: "1,2,3"}, {desc: "RGBAの書式不正(値の数過多)", colstr: "1,2,3,4,5"}, {desc: "RGBAの書式不正(値がない)", colstr: "1,2,3,"}, {desc: "RGBAの書式不正(値に文字が混じっている)", colstr: "1,2,3,a"}, {desc: "RGBAの書式不正(255以上の値)", colstr: "1,2,3,256"}, {desc: "RGBAの書式不正(負の値)", colstr: "-1,2,3,255"}, {desc: "RGBAの書式不正(空文字)", colstr: ""}, } for _, v := range tds { t.Run(v.desc, func(t *testing.T) { _, err := optionColorStringToRGBA(v.colstr) assert.NotNil(t, err, v.desc) }) } } func TestToSlideStrings(t *testing.T) { type TestData struct { desc string src, expect []string lineCount, slideWidth int slideForever bool } tds := []TestData{ { desc: "2行描画、スライド幅1、無限なし", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "2", "3", "3", "4", "4", "5", }, lineCount: 2, slideWidth: 1, slideForever: false, }, { desc: "2行描画、スライド幅2、無限なし", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "3", "4", "5", "", }, lineCount: 2, slideWidth: 2, slideForever: false, }, { desc: "3行描画、スライド幅1、無限なし", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "3", "2", "3", "4", "3", "4", "5", }, lineCount: 3, slideWidth: 1, slideForever: false, }, { desc: "3行描画、スライド幅2、無限なし、不足あり", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "3", "4", "5", "5", "6", "", }, lineCount: 3, slideWidth: 2, slideForever: false, }, { desc: "3行描画、スライド幅2、無限なし、不足なし", src: []string{"1", "2", "3", "4", "5", "6", "7"}, expect: []string{ "1", "2", "3", "3", "4", "5", "5", "6", "7", }, lineCount: 3, slideWidth: 2, slideForever: false, }, { desc: "3行描画、スライド幅3、無限なし、不足なし", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "4", "5", "6", }, lineCount: 3, slideWidth: 3, slideForever: false, }, { desc: "3行描画、スライド幅3、無限なし、不足あり", src: []string{"1", "2", "3", "4", "5", "6", "7"}, expect: []string{ "1", "2", "3", "4", "5", "6", "7", "", "", }, lineCount: 3, slideWidth: 3, slideForever: false, }, { desc: "3行描画、スライド幅3、無限なし、不足あり", src: []string{"1", "2", "3", "4", "5", "6", "7", "8"}, expect: []string{ "1", "2", "3", "4", "5", "6", "7", "8", "", }, lineCount: 3, slideWidth: 3, slideForever: false, }, { desc: "2行描画、スライド幅2、無限あり", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "3", "4", "5", "1", }, lineCount: 2, slideWidth: 2, slideForever: true, }, { desc: "2行描画、スライド幅2、無限あり", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "4", "5", "6", }, lineCount: 2, slideWidth: 2, slideForever: true, }, { desc: "3行描画、スライド幅1、無限あり", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "3", "2", "3", "4", "3", "4", "5", "4", "5", "1", "5", "1", "2", }, lineCount: 3, slideWidth: 1, slideForever: true, }, { desc: "3行描画、スライド幅1、無限あり", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "2", "3", "4", "3", "4", "5", "4", "5", "6", "5", "6", "1", "6", "1", "2", }, lineCount: 3, slideWidth: 1, slideForever: true, }, { desc: "3行描画、スライド幅2、無限あり", src: []string{"1", "2", "3", "4", "5"}, expect: []string{ "1", "2", "3", "3", "4", "5", "5", "1", "2", }, lineCount: 3, slideWidth: 2, slideForever: true, }, { desc: "3行描画、スライド幅2、無限あり", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "3", "4", "5", "5", "6", "1", }, lineCount: 3, slideWidth: 2, slideForever: true, }, { desc: "3行描画、スライド幅3、無限あり", src: []string{"1", "2", "3", "4", "5", "6"}, expect: []string{ "1", "2", "3", "4", "5", "6", }, lineCount: 3, slideWidth: 3, slideForever: true, }, { desc: "3行描画、スライド幅3、無限あり", src: []string{"1", "2", "3", "4", "5", "6", "7"}, expect: []string{ "1", "2", "3", "4", "5", "6", "7", "1", "2", }, lineCount: 3, slideWidth: 3, slideForever: true, }, } for _, v := range tds { t.Run(v.desc, func(t *testing.T) { got := toSlideStrings(v.src, v.lineCount, v.slideWidth, v.slideForever) assert.Equal(t, v.expect, got, v.desc) }) } } func TestRemoveZeroWidthCharacters(t *testing.T) { type TestData struct { desc string s string expect string } tds := []TestData{ {desc: "Zero width space (U+200B)が削除される", s: "A\u200bB", expect: "AB"}, {desc: "Zero width joiner (U+200C)が削除される", s: "A\u200cB", expect: "AB"}, {desc: "Zero width joiner (U+200D)が削除される", s: "A\u200dB", expect: "AB"}, {desc: "U+200B ~ U+200Dが削除される", s: "あ\u200bい\u200cう\u200dえ", expect: "あいうえ"}, } for _, v := range tds { t.Run(v.desc, func(t *testing.T) { got := removeZeroWidthCharacters(v.s) assert.Equal(t, v.expect, got, v.desc) }) } } func TestApplicationConfigSetFontFileAndFontIndex(t *testing.T) { type TestData struct { desc string inFontFile string inFontIndex int inRuntimeOS string wantFontFile string wantFontIndex int } tests := []TestData{ { desc: "正常系: FontFileが設定済みの場合は変更なし", inFontFile: "/usr/share/fonts/寿司", inFontIndex: 0, inRuntimeOS: "linux", wantFontFile: "/usr/share/fonts/寿司", wantFontIndex: 0, }, { desc: "正常系: フォント未設定でwindowsの場合はwindows用のフォントが設定される", inRuntimeOS: "windows", wantFontFile: defaultWindowsFont, wantFontIndex: 0, }, { desc: "正常系: フォント未設定でdarwinの場合はdarwin用のフォントが設定される", inRuntimeOS: "darwin", wantFontFile: defaultDarwinFont, wantFontIndex: 0, }, { desc: "正常系: フォント未設定でiosの場合はios用のフォントが設定される", inRuntimeOS: "ios", wantFontFile: defaultIOSFont, wantFontIndex: 0, }, { desc: "正常系: フォント未設定でandroidの場合はandroid用のフォントが設定される", inRuntimeOS: "android", wantFontFile: defaultAndroidFont, wantFontIndex: 5, }, // FIXME: ローカル環境で実行するとエラーになるので一旦無効化 // { // desc: "正常系: フォント未設定でlinuxの場合はlinux用のフォントが設定される。Linux用のフォントは2つ存在するが、1つ目のフォントはalpineコンテナ内にデフォルトでは存在しないため2つ目が設定される", // inRuntimeOS: "linux", // wantFontFile: defaultLinuxFont2, // wantFontIndex: 5, // }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) a := Config{ FontFile: tt.inFontFile, FontIndex: tt.inFontIndex, } a.SetFontFileAndFontIndex(tt.inRuntimeOS) assert.Equal(tt.wantFontFile, a.FontFile) assert.Equal(tt.wantFontIndex, a.FontIndex) }) } } func TestApplicationConfig_AddTimeStampToOutPath(t *testing.T) { type TestData struct { desc string inOutpath string inAddTimeStamp bool inTime time.Time want string } tests := []TestData{ { desc: "正常系: フラグfalseの場合は変更なし", inOutpath: "t.png", inAddTimeStamp: false, inTime: time.Date(2000, 1, 1, 12, 10, 5, 2, time.Local), want: "t.png", }, { desc: "正常系: フラグtrueの場合はタイムスタンプがつく", inOutpath: "t.png", inAddTimeStamp: true, inTime: time.Date(2000, 1, 1, 12, 10, 5, 0, time.Local), want: "t_2000-01-01-121005.png", }, { desc: "正常系: フルパスでも同様に動作する", inOutpath: "/images/t.png", inAddTimeStamp: true, inTime: time.Date(2000, 1, 1, 12, 10, 5, 0, time.Local), want: "/images/t_2000-01-01-121005.png", }, { desc: "正常系: ファイル拡張子が多重についていても動作する", inOutpath: "/images/t.png.1", inAddTimeStamp: true, inTime: time.Date(2000, 1, 1, 12, 10, 5, 0, time.Local), want: "/images/t.png_2000-01-01-121005.1", }, { desc: "正常系: Windowsのパス表現でも動作する", inOutpath: `C:\Users\foobar\Pictures\t.png`, inAddTimeStamp: true, inTime: time.Date(2000, 1, 1, 12, 10, 5, 0, time.Local), want: `C:\Users\foobar\Pictures\t_2000-01-01-121005.png`, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) a := Config{ Outpath: tt.inOutpath, AddTimeStamp: tt.inAddTimeStamp, } a.addTimeStampToOutPath(tt.inTime) assert.Equal(tt.want, a.Outpath) }) } } func TestOutputImageDir(t *testing.T) { home, err := os.UserHomeDir() assert.NoError(t, err) pictDir := filepath.Join(home, "Pictures") type TestData struct { desc string inEnvDir string inUseAnimation bool wantPath string wantErr bool } tests := []TestData{ { desc: "正常系: Env未設定の場合はホームディレクトリ配下のPictures配下が返る", inEnvDir: "", inUseAnimation: false, wantPath: filepath.Join(pictDir, "t.png"), wantErr: false, }, { desc: "正常系: animation trueの場合は Basenameが t.gif になる", inEnvDir: "", inUseAnimation: true, wantPath: filepath.Join(pictDir, "t.gif"), wantErr: false, }, { desc: "正常系: Envが設定されている場合は設定されている値が優先される", inEnvDir: filepath.Join(".", "sushi"), inUseAnimation: false, wantPath: filepath.Join(".", "sushi", "t.png"), wantErr: false, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) got, err := outputImageDir(tt.inEnvDir, tt.inUseAnimation) if tt.wantErr { assert.Equal("", got) assert.Error(err) return } assert.NoError(err) assert.Equal(tt.wantPath, got) }) } } func TestApplicationConfig_AddNumberSuffixToOutPath(t *testing.T) { testdataDir := filepath.Join("..", "testdata", "in") existedFile := filepath.Join(testdataDir, "appconf_add_number_suffix_test_case1.png") existedFileWant := filepath.Join(testdataDir, "appconf_add_number_suffix_test_case1_2.png") notExistedFile := filepath.Join(testdataDir, "appconf_add_number_suffix_sushi.png") type TestData struct { desc string inOutpath string inSaveNumberedFile bool want string } tests := []TestData{ { desc: "正常系: フラグfalseの場合は変更なし", inOutpath: existedFile, inSaveNumberedFile: false, want: existedFile, }, { desc: "正常系: フラグtrueの場合は連番を付与する", inOutpath: existedFile, inSaveNumberedFile: true, want: existedFileWant, }, { desc: "正常系: フラグtrueの場合でも、ファイルが存在しなければ何もしない", inOutpath: notExistedFile, inSaveNumberedFile: true, want: notExistedFile, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) a := Config{ Outpath: tt.inOutpath, SaveNumberedFile: tt.inSaveNumberedFile, } a.addNumberSuffixToOutPath() assert.Equal(tt.want, a.Outpath) }) } } ================================================ FILE: config/envvar.go ================================================ package config import ( "fmt" "os" ) type EnvVars struct { EmojiDir string OutputDir string FontFile string EmojiFontFile string } const ( envNameOutputDir = "TEXTIMG_OUTPUT_DIR" envNameFontFile = "TEXTIMG_FONT_FILE" envNameEmojiDir = "TEXTIMG_EMOJI_DIR" envNameEmojiFontFile = "TEXTIMG_EMOJI_FONT_FILE" ) var ( envs = map[string]string{ envNameOutputDir: os.Getenv(envNameOutputDir), envNameFontFile: os.Getenv(envNameFontFile), envNameEmojiDir: os.Getenv(envNameEmojiDir), envNameEmojiFontFile: os.Getenv(envNameEmojiFontFile), } ) func NewEnvVars() EnvVars { return EnvVars{ OutputDir: envs[envNameOutputDir], FontFile: envs[envNameFontFile], EmojiDir: envs[envNameEmojiDir], EmojiFontFile: envs[envNameEmojiFontFile], } } func PrintEnvs() { for k, v := range envs { text := fmt.Sprintf("%s=%s", k, v) fmt.Println(text) } } ================================================ FILE: config/face.go ================================================ package config import ( "os" "path/filepath" "strings" "github.com/jiro4989/textimg/v3/log" "golang.org/x/image/font" "golang.org/x/image/font/gofont/gomono" "golang.org/x/image/font/opentype" ) // readFace はfontPathのフォントファイルからfaceを返す。 func readFace(fontPath string, fontIndex int, fontSize float64) (font.Face, error) { var ft *opentype.Font // ファイルが存在しなければビルトインのフォントをデフォルトとして使う _, err := os.Stat(fontPath) if err == nil { fontData, err := os.ReadFile(fontPath) if err != nil { return nil, err } switch strings.ToLower(filepath.Ext(fontPath)) { case ".otc", ".ttc": ftc, err := opentype.ParseCollection(fontData) if err != nil { return nil, err } ft, err = ftc.Font(fontIndex) if err != nil { return nil, err } default: ft, err = opentype.Parse(fontData) if err != nil { return nil, err } } } else { log.Warnf("%s is not found. please set font path with `-f` option", fontPath) ft, err = opentype.Parse(gomono.TTF) if err != nil { return nil, err } } opt := opentype.FaceOptions{ Size: fontSize, DPI: 72, Hinting: 0, } face, err := opentype.NewFace(ft, &opt) if err != nil { return nil, err } return face, nil } ================================================ FILE: config/face_test.go ================================================ package config import ( "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestReadFace(t *testing.T) { testdataDir := filepath.Join("..", "testdata", "in") type TestData struct { desc string inFontPath string inFontIndex int wantErr bool } tests := []TestData{ { desc: "正常系: font.Faceが取得できる", inFontPath: "/tmp/MyricaM.TTC", inFontIndex: 0, wantErr: false, }, { desc: "正常系: 存在しないファイルの場合もエラーにはならない", inFontPath: "/tmp/寿司", inFontIndex: 0, wantErr: false, }, { desc: "異常系: パスとしては存在するがディレクトリの場合はエラー", inFontPath: "/tmp", inFontIndex: 0, wantErr: true, }, { desc: "異常系: ファイルは存在するけれど、フォントファイルじゃない時はエラー (ttc)", inFontPath: filepath.Join(testdataDir, "illegal_font.ttc"), inFontIndex: 0, wantErr: true, }, { desc: "異常系: ファイルは存在するけれど、フォントファイルじゃない時はエラー (otc)", inFontPath: filepath.Join(testdataDir, "illegal_font.otc"), inFontIndex: 0, wantErr: true, }, { desc: "異常系: ファイルは存在するけれど、フォントファイルじゃない時はエラー (txt)", inFontPath: filepath.Join(testdataDir, "illegal_font.txt"), inFontIndex: 0, wantErr: true, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) got, err := readFace(tt.inFontPath, tt.inFontIndex, 20) if tt.wantErr { assert.Nil(got) assert.Error(err) return } assert.NotNil(got) assert.NoError(err) }) } } ================================================ FILE: config/writer_mock.go ================================================ package config import ( "errors" "io" ) type MockWriter struct { writeErr bool closeErr bool } func NewMockWriter(w, c bool) io.WriteCloser { return &MockWriter{ writeErr: w, closeErr: c, } } func (m *MockWriter) Write(p []byte) (n int, err error) { if m.writeErr { return -1, errors.New("write error") } return 0, nil } func (m *MockWriter) Close() error { if m.closeErr { return errors.New("close error") } return nil } ================================================ FILE: docker-compose.yml ================================================ --- version: '3.7' services: base: &common build: context: ./ dockerfile: ./Dockerfile target: base container_name: textimg_base image: jiro4989/textimg-base working_dir: /app volumes: - "$PWD:/app" - "gopkg:/go/pkg" # 名前付きボリュームで依存パッケージを永続化 - "$PWD/images:/images" environment: TEXTIMG_FONT_FILE: /tmp/MyricaM.TTC TEXTIMG_EMOJI_DIR: /usr/local/src/noto-emoji/png/128 TEXTIMG_EMOJI_FONT_FILE: /tmp/Symbola_hint.ttf textimg: build: context: ./ dockerfile: ./Dockerfile container_name: textimg image: jiro4989/textimg volumes: - "$PWD/images:/images" volumes: gopkg: ================================================ FILE: go.mod ================================================ module github.com/jiro4989/textimg/v3 go 1.25.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.2.0 // indirect github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/image v0.39.0 golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require github.com/oliamb/cutter v0.2.2 require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect ) ================================================ FILE: go.sum ================================================ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k= github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: image/encode.go ================================================ package image import ( "fmt" "image" "image/color/palette" "image/draw" "image/gif" "image/jpeg" "image/png" "io" ) func (i *Image) Encode(w io.Writer, ext string) error { img := i.image switch ext { case ".png": return png.Encode(w, img) case ".jpg", ".jpeg": return jpeg.Encode(w, img, nil) case ".gif": if i.useAnimation { var delays []int for x := 0; x < len(i.animationImages); x++ { delays = append(delays, i.delay) } return gif.EncodeAll(w, &gif.GIF{ Image: toPalettes(i.animationImages), Delay: delays, }) } return gif.Encode(w, img, nil) } return fmt.Errorf("%s is not supported extension.", ext) } func toPalettes(imgs []image.Image) (ret []*image.Paletted) { for _, v := range imgs { bounds := v.Bounds() p := image.NewPaletted(bounds, palette.Plan9) draw.Draw(p, p.Rect, v, bounds.Min, draw.Over) ret = append(ret, p) } return } ================================================ FILE: image/image.go ================================================ package image import ( "image" c "image/color" "image/draw" "os" "github.com/jiro4989/textimg/v3/color" "github.com/jiro4989/textimg/v3/token" "github.com/mattn/go-runewidth" "github.com/oliamb/cutter" xdraw "golang.org/x/image/draw" "golang.org/x/image/font" "golang.org/x/image/math/fixed" ) type ( Image struct { image *image.RGBA animationImages []image.Image x int y int foregroundColor c.RGBA // 文字色 backgroundColor c.RGBA // 背景色 defaultForegroundColor c.RGBA // 文字色 defaultBackgroundColor c.RGBA // 背景色 fontSize int // フォントサイズ fontFace font.Face emojiFontFace font.Face charWidth int charHeight int emojiDir string useEmoji bool lineCount int useAnimation bool animationLineCount int animationImageFlameHeight int resizeWidth int resizeHeight int delay int } ImageParam struct { BaseWidth int BaseHeight int ForegroundColor c.RGBA // 文字色 BackgroundColor c.RGBA // 背景色 FontSize int // フォントサイズ FontFace font.Face EmojiFontFace font.Face EmojiDir string UseEmoji bool UseAnimation bool AnimationLineCount int ResizeWidth int ResizeHeight int Delay int } ) func init() { // Unicode Neutral で定義されている絵文字(例: 👁)を幅2として扱う runewidth.DefaultCondition.StrictEmojiNeutral = false } func NewImage(p *ImageParam) *Image { var ( charWidth = p.FontSize / 2 charHeight = int(float64(p.FontSize) * 1.1) imageWidth = p.BaseWidth * charWidth imageHeight = p.BaseHeight * charHeight ) var animationImageFlameHeight int if p.UseAnimation { animationImageFlameHeight = imageHeight / (p.BaseHeight / p.AnimationLineCount) } image := newImage(imageWidth, imageHeight) return &Image{ image: image, foregroundColor: p.ForegroundColor, backgroundColor: p.BackgroundColor, defaultForegroundColor: p.ForegroundColor, defaultBackgroundColor: p.BackgroundColor, fontSize: p.FontSize, fontFace: p.FontFace, emojiFontFace: p.EmojiFontFace, charWidth: charWidth, charHeight: charHeight, emojiDir: p.EmojiDir, useEmoji: p.UseEmoji, useAnimation: p.UseAnimation, animationLineCount: p.AnimationLineCount, animationImageFlameHeight: animationImageFlameHeight, resizeWidth: p.ResizeWidth, resizeHeight: p.ResizeHeight, delay: p.Delay, } } func newImage(w, h int) *image.RGBA { return image.NewRGBA(image.Rect(0, 0, w, h)) } func (i *Image) Draw(tokens token.Tokens) error { i.drawBackgroundAll() // 背景のみ描画 for _, t := range tokens { switch t.Kind { case token.KindColor: i.updateColor(t.ColorType, t.Color) case token.KindText: i.drawBackground(t.Text) for _, r := range t.Text { if isLinefeed(r) { i.moveDown() continue } i.moveRight(r) } } } i.resetColor() i.resetPosition() // 文字のみ描画 for _, t := range tokens { switch t.Kind { case token.KindColor: i.updateColor(t.ColorType, t.Color) case token.KindText: for _, r := range t.Text { if isLinefeed(r) { i.moveDown() continue } if err := i.draw(r); err != nil { return err } i.moveRight(r) } } } if err := i.setAnimationFlames(); err != nil { return err } i.scale() return nil } // 背景色をデフォルト色で塗りつぶす。 func (i *Image) drawBackgroundAll() { var ( bounds = i.image.Bounds().Max width = bounds.X height = bounds.Y ) for x := 0; x < width; x++ { for y := 0; y < height; y++ { i.image.Set(x, y, c.RGBA(i.defaultBackgroundColor)) } } } func (i *Image) updateColor(t token.ColorType, col color.RGBA) { switch t { case token.ColorTypeReset: i.resetColor() case token.ColorTypeResetForeground: i.foregroundColor = i.defaultForegroundColor case token.ColorTypeResetBackground: i.backgroundColor = i.defaultBackgroundColor case token.ColorTypeReverse: i.foregroundColor, i.backgroundColor = i.backgroundColor, i.foregroundColor case token.ColorTypeForeground: i.foregroundColor = c.RGBA(col) case token.ColorTypeBackground: i.backgroundColor = c.RGBA(col) } } func (i *Image) resetColor() { i.foregroundColor = i.defaultForegroundColor i.backgroundColor = i.defaultBackgroundColor } func (i *Image) resetPosition() { i.x = 0 i.y = 0 i.lineCount = 0 } func (i *Image) newDrawer(f font.Face) *font.Drawer { // 特殊な位置調整。なんでこんな計算式にしたのか覚えていない var ( x = i.x y = i.y + i.charHeight - (i.charHeight / 5) ) // FIXME: なんか警告出てる point := fixed.Point26_6{fixed.Int26_6(x * 64), fixed.Int26_6(y * 64)} d := &font.Drawer{ Dst: i.image, Src: image.NewUniform(c.RGBA(i.foregroundColor)), Face: f, Dot: point, } return d } func (i *Image) draw(r rune) error { if ok, emojiPath := isEmoji(r, i.emojiDir); ok { if i.useEmoji { i.drawRune(r, i.emojiFontFace) return nil } return i.drawEmoji(r, emojiPath) } i.drawRune(r, i.fontFace) return nil } func (i *Image) setAnimationFlames() error { if i.useAnimation { b := i.image.Bounds().Max w, h := b.X, i.animationImageFlameHeight max := b.Y / i.animationImageFlameHeight for rc := 0; rc < max; rc++ { x, y := 0, rc*h pt := image.Pt(x, y) cimg, err := cutter.Crop(i.image, cutter.Config{ Width: w, Height: h, Anchor: pt, Mode: cutter.TopLeft, Options: cutter.Copy, }) if err != nil { return err } dist := image.NewRGBA(image.Rectangle{ image.Pt(0, 0), image.Pt(w, h), }) draw.Draw(dist, dist.Bounds(), cimg, pt, draw.Over) i.animationImages = append(i.animationImages, dist) } } return nil } // rune文字を画像に書き込む。 // 書き込み終えると座標を更新する。 func (i *Image) drawRune(r rune, f font.Face) { d := i.newDrawer(f) d.DrawString(string(r)) } func (i *Image) drawEmoji(r rune, path string) error { fp, err := os.Open(path) if err != nil { return err } defer fp.Close() emoji, _, err := image.Decode(fp) if err != nil { return err } d := i.newDrawer(i.fontFace) // 画像サイズをフォントサイズに合わせる // 0.9でさらに微妙に調整 size := int(float64(d.Face.Metrics().Ascent.Floor()+d.Face.Metrics().Descent.Floor()) * 0.9) rect := image.Rect(0, 0, size, size) dst := image.NewRGBA(rect) xdraw.ApproxBiLinear.Scale(dst, rect, emoji, emoji.Bounds(), draw.Over, nil) p := image.Pt(d.Dot.X.Floor(), d.Dot.Y.Floor()-d.Face.Metrics().Ascent.Floor()) draw.Draw(i.image, rect.Add(p), dst, image.Point{}, draw.Over) return nil } func (i *Image) drawBackground(s string) { var ( tw = runewidth.StringWidth(s) width = tw * i.charWidth height = i.charHeight posX = i.x posY = i.y ) for x := posX; x < posX+width; x++ { for y := posY; y < posY+height; y++ { i.image.Set(x, y, c.RGBA(i.backgroundColor)) } } } func (i *Image) moveRight(r rune) { i.x += runewidth.RuneWidth(r) * i.charWidth } func (i *Image) moveDown() { i.x = 0 i.y += i.charHeight i.lineCount++ } func (i *Image) newScaledImage() *image.RGBA { if i.resizeWidth == 0 && i.resizeHeight == 0 { return i.image } // 呼び出し側で大きさを調整していること dst := scale(i.image, i.resizeWidth, i.resizeHeight) return dst } func scale(img image.Image, w, h int) *image.RGBA { rect := img.Bounds() dst := newImage(w, h) xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, rect, draw.Over, nil) return dst } func (i *Image) scale() { if i.resizeWidth == 0 && i.resizeHeight == 0 { return } i.image = i.newScaledImage() for j, img := range i.animationImages { dst := scale(img, i.resizeWidth, i.resizeHeight) i.animationImages[j] = dst } } ================================================ FILE: image/util.go ================================================ package image import ( "fmt" "os" ) var ( // 絵文字描画の際に、普通に描画してほしいけれど絵文字としても定義されている // 文字のコードポイント exRunes = []rune{ 0x0023, // # 0x002A, // * 0x0030, // 0 0x0031, // 1 0x0032, // 2 0x0033, // 3 0x0034, // 4 0x0035, // 5 0x0036, // 6 0x0037, // 7 0x0038, // 8 0x0039, // 9 0x00A9, // © 0x00AE, // ®️ } ) // コードポイントに対応する画像ファイルかどうかを判定する。 // 画像ファイルだった場合は当該画像ファイルのパスを返却する。 func isEmoji(r rune, emojiDir string) (bool, string) { path := fmt.Sprintf("%s/emoji_u%.4x.png", emojiDir, r) _, err := os.Stat(path) if err == nil && !isExceptionallyCodePoint(r) { return true, path } return false, "" } // r が例外的なコードポイントに存在するかを判定する。 // http://unicode.org/Public/emoji/4.0/emoji-data.txt // // ここでtrueを返す文字は、絵文字データ的には絵文字ではあるものの、 // シェル芸bot環境ではテキストとして表示したいので例外的に除外するために指定して // いる。 func isExceptionallyCodePoint(r rune) bool { for _, ex := range exRunes { if r == ex { return true } } return false } func isLinefeed(r rune) bool { return r == '\n' } ================================================ FILE: images/.gitkeep ================================================ ================================================ FILE: internal/global/env.go ================================================ package global const ( EnvNameOutputDir = "TEXTIMG_OUTPUT_DIR" EnvNameFontFile = "TEXTIMG_FONT_FILE" EnvNameEmojiDir = "TEXTIMG_EMOJI_DIR" EnvNameEmojiFontFile = "TEXTIMG_EMOJI_FONT_FILE" ) var ( EnvNames = []string{ EnvNameOutputDir, EnvNameFontFile, EnvNameEmojiDir, EnvNameEmojiFontFile, } ) ================================================ FILE: internal/global/version.go ================================================ package global const ( AppName = "textimg" Version = `3.1.10 Copyright (c) 2019 jiro4989 Released under the MIT License. https://github.com/jiro4989/textimg` ) ================================================ FILE: log/log.go ================================================ package log import ( "fmt" "os" "runtime" "time" "github.com/jiro4989/textimg/v3/internal/global" ) const ( debugPrefix = "[DEBUG]" infoPrefix = "[INFO]" warnPrefix = "[WARN]" errorPrefix = "[ERROR]" ) func log(lvl string, msg interface{}) { _, f, l, ok := runtime.Caller(2) if !ok { fmt.Fprintln(os.Stderr, "something error occurred.") return } now := time.Now().Format("2006/01/02 03:04:05") text := fmt.Sprintf("%s %s %s %s:%d %v", now, global.AppName, lvl, f, l, msg) fmt.Fprintln(os.Stderr, text) } func Debug(msg interface{}) { log(debugPrefix, msg) } func Info(msg interface{}) { log(infoPrefix, msg) } func Warn(msg interface{}) { log(warnPrefix, msg) } func Warnf(format string, msg interface{}) { text := fmt.Sprintf(format, msg) log(warnPrefix, text) } func Error(msg interface{}) { log(errorPrefix, msg) } ================================================ FILE: log/log_test.go ================================================ package log import ( "testing" ) func TestDebug(t *testing.T) { Debug("debug") Info("debug") Warn("debug") Warnf("%v", 1) Error("debug") } ================================================ FILE: main.go ================================================ package main import ( "os" ) func main() { os.Exit(Main()) } func Main() int { if err := RootCommand.Execute(); err != nil { return -1 } return 0 } ================================================ FILE: main_test.go ================================================ //go:build !docker package main import ( "os" "testing" ) const ( inDir = "testdata/in" outDir = "testdata/out" ) func TestMain(m *testing.M) { testBefore() exitCode := m.Run() os.Exit(exitCode) } func testBefore() { if err := os.RemoveAll(outDir); err != nil { panic(err) } // nolint os.Mkdir(outDir, os.ModePerm) } ================================================ FILE: parser/grammar.peg ================================================ package parser type Parser Peg { ParserFunc } root <- (colors / ignore / text)* ignore <- prefix number? non_color_suffix / escape_sequence colors <- prefix color_suffix { p.pushResetColor() } / prefix color (delimiter color)* color_suffix text <- < [^\e] + > { p.pushText(text) } color <- standard_color / extended_color / text_attributes standard_color <- zero < ([349] / '10') [0-7] > { p.pushStandardColorWithCategory(text) } / zero < [39] '9' > { p.pushResetForegroundColor() } / zero < ('4' / '10') '9' > { p.pushResetBackgroundColor() } extended_color <- extended_color_256 / extended_color_rgb extended_color_256 <- extended_color_prefix delimiter zero '5' delimiter < number > { p.setExtendedColor256(text) } extended_color_rgb <- extended_color_prefix delimiter zero '2' delimiter < number > { p.setExtendedColorR(text) } delimiter < number > { p.setExtendedColorG(text) } delimiter < number > { p.setExtendedColorB(text) } extended_color_prefix <- zero < [34] '8' > { p.pushExtendedColor(text) } text_attributes <- ( '0' { p.pushResetColor() } / '7' { p.pushReverseColor() } / [1458] )+ zero <- '0' * number <- [0-9]+ prefix <- escape_sequence '[' escape_sequence <- '\e' color_suffix <- 'm' non_color_suffix <- [A-HfSTJK] delimiter <- ';' ================================================ FILE: parser/grammar.peg.go ================================================ package parser // Code generated by peg parser/grammar.peg DO NOT EDIT. import ( "fmt" "io" "os" "sort" "strconv" "strings" ) const endSymbol rune = 1114112 /* The rule types inferred from the grammar are below. */ type pegRule uint8 const ( ruleUnknown pegRule = iota ruleroot ruleignore rulecolors ruletext rulecolor rulestandard_color ruleextended_color ruleextended_color_256 ruleextended_color_rgb ruleextended_color_prefix ruletext_attributes rulezero rulenumber ruleprefix ruleescape_sequence rulecolor_suffix rulenon_color_suffix ruledelimiter ruleAction0 rulePegText ruleAction1 ruleAction2 ruleAction3 ruleAction4 ruleAction5 ruleAction6 ruleAction7 ruleAction8 ruleAction9 ruleAction10 ruleAction11 ) var rul3s = [...]string{ "Unknown", "root", "ignore", "colors", "text", "color", "standard_color", "extended_color", "extended_color_256", "extended_color_rgb", "extended_color_prefix", "text_attributes", "zero", "number", "prefix", "escape_sequence", "color_suffix", "non_color_suffix", "delimiter", "Action0", "PegText", "Action1", "Action2", "Action3", "Action4", "Action5", "Action6", "Action7", "Action8", "Action9", "Action10", "Action11", } type token32 struct { pegRule begin, end uint32 } func (t *token32) String() string { return fmt.Sprintf("\x1B[34m%v\x1B[m %v %v", rul3s[t.pegRule], t.begin, t.end) } type node32 struct { token32 up, next *node32 } func (node *node32) print(w io.Writer, pretty bool, buffer string) { var print func(node *node32, depth int) print = func(node *node32, depth int) { for node != nil { for c := 0; c < depth; c++ { fmt.Fprintf(w, " ") } rule := rul3s[node.pegRule] quote := strconv.Quote(string(([]rune(buffer)[node.begin:node.end]))) if !pretty { fmt.Fprintf(w, "%v %v\n", rule, quote) } else { fmt.Fprintf(w, "\x1B[36m%v\x1B[m %v\n", rule, quote) } if node.up != nil { print(node.up, depth+1) } node = node.next } } print(node, 0) } func (node *node32) Print(w io.Writer, buffer string) { node.print(w, false, buffer) } func (node *node32) PrettyPrint(w io.Writer, buffer string) { node.print(w, true, buffer) } type tokens32 struct { tree []token32 } func (t *tokens32) Trim(length uint32) { t.tree = t.tree[:length] } func (t *tokens32) Print() { for _, token := range t.tree { fmt.Println(token.String()) } } func (t *tokens32) AST() *node32 { type element struct { node *node32 down *element } tokens := t.Tokens() var stack *element for _, token := range tokens { if token.begin == token.end { continue } node := &node32{token32: token} for stack != nil && stack.node.begin >= token.begin && stack.node.end <= token.end { stack.node.next = node.up node.up = stack.node stack = stack.down } stack = &element{node: node, down: stack} } if stack != nil { return stack.node } return nil } func (t *tokens32) PrintSyntaxTree(buffer string) { t.AST().Print(os.Stdout, buffer) } func (t *tokens32) WriteSyntaxTree(w io.Writer, buffer string) { t.AST().Print(w, buffer) } func (t *tokens32) PrettyPrintSyntaxTree(buffer string) { t.AST().PrettyPrint(os.Stdout, buffer) } func (t *tokens32) Add(rule pegRule, begin, end, index uint32) { tree, i := t.tree, int(index) if i >= len(tree) { t.tree = append(tree, token32{pegRule: rule, begin: begin, end: end}) return } tree[i] = token32{pegRule: rule, begin: begin, end: end} } func (t *tokens32) Tokens() []token32 { return t.tree } type Parser struct { ParserFunc Buffer string buffer []rune rules [32]func() bool parse func(rule ...int) error reset func() Pretty bool tokens32 } func (p *Parser) Parse(rule ...int) error { return p.parse(rule...) } func (p *Parser) Reset() { p.reset() } type textPosition struct { line, symbol int } type textPositionMap map[int]textPosition func translatePositions(buffer []rune, positions []int) textPositionMap { length, translations, j, line, symbol := len(positions), make(textPositionMap, len(positions)), 0, 1, 0 sort.Ints(positions) search: for i, c := range buffer { if c == '\n' { line, symbol = line+1, 0 } else { symbol++ } if i == positions[j] { translations[positions[j]] = textPosition{line, symbol} for j++; j < length; j++ { if i != positions[j] { continue search } } break search } } return translations } type parseError struct { p *Parser max token32 } func (e *parseError) Error() string { tokens, err := []token32{e.max}, "\n" positions, p := make([]int, 2*len(tokens)), 0 for _, token := range tokens { positions[p], p = int(token.begin), p+1 positions[p], p = int(token.end), p+1 } translations := translatePositions(e.p.buffer, positions) format := "parse error near %v (line %v symbol %v - line %v symbol %v):\n%v\n" if e.p.Pretty { format = "parse error near \x1B[34m%v\x1B[m (line %v symbol %v - line %v symbol %v):\n%v\n" } for _, token := range tokens { begin, end := int(token.begin), int(token.end) err += fmt.Sprintf(format, rul3s[token.pegRule], translations[begin].line, translations[begin].symbol, translations[end].line, translations[end].symbol, strconv.Quote(string(e.p.buffer[begin:end]))) } return err } func (p *Parser) PrintSyntaxTree() { if p.Pretty { p.tokens32.PrettyPrintSyntaxTree(p.Buffer) } else { p.tokens32.PrintSyntaxTree(p.Buffer) } } func (p *Parser) WriteSyntaxTree(w io.Writer) { p.tokens32.WriteSyntaxTree(w, p.Buffer) } func (p *Parser) SprintSyntaxTree() string { var bldr strings.Builder p.WriteSyntaxTree(&bldr) return bldr.String() } func (p *Parser) Execute() { buffer, _buffer, text, begin, end := p.Buffer, p.buffer, "", 0, 0 for _, token := range p.Tokens() { switch token.pegRule { case rulePegText: begin, end = int(token.begin), int(token.end) text = string(_buffer[begin:end]) case ruleAction0: p.pushResetColor() case ruleAction1: p.pushText(text) case ruleAction2: p.pushStandardColorWithCategory(text) case ruleAction3: p.pushResetForegroundColor() case ruleAction4: p.pushResetBackgroundColor() case ruleAction5: p.setExtendedColor256(text) case ruleAction6: p.setExtendedColorR(text) case ruleAction7: p.setExtendedColorG(text) case ruleAction8: p.setExtendedColorB(text) case ruleAction9: p.pushExtendedColor(text) case ruleAction10: p.pushResetColor() case ruleAction11: p.pushReverseColor() } } _, _, _, _, _ = buffer, _buffer, text, begin, end } func Pretty(pretty bool) func(*Parser) error { return func(p *Parser) error { p.Pretty = pretty return nil } } func Size(size int) func(*Parser) error { return func(p *Parser) error { p.tokens32 = tokens32{tree: make([]token32, 0, size)} return nil } } func (p *Parser) Init(options ...func(*Parser) error) error { var ( max token32 position, tokenIndex uint32 buffer []rune ) for _, option := range options { err := option(p) if err != nil { return err } } p.reset = func() { max = token32{} position, tokenIndex = 0, 0 p.buffer = []rune(p.Buffer) if len(p.buffer) == 0 || p.buffer[len(p.buffer)-1] != endSymbol { p.buffer = append(p.buffer, endSymbol) } buffer = p.buffer } p.reset() _rules := p.rules tree := p.tokens32 p.parse = func(rule ...int) error { r := 1 if len(rule) > 0 { r = rule[0] } matches := p.rules[r]() p.tokens32 = tree if matches { p.Trim(tokenIndex) return nil } return &parseError{p, max} } add := func(rule pegRule, begin uint32) { tree.Add(rule, begin, position, tokenIndex) tokenIndex++ if begin != position && position > max.end { max = token32{rule, begin, position} } } matchDot := func() bool { if buffer[position] != endSymbol { position++ return true } return false } /*matchChar := func(c byte) bool { if buffer[position] == c { position++ return true } return false }*/ /*matchRange := func(lower byte, upper byte) bool { if c := buffer[position]; c >= lower && c <= upper { position++ return true } return false }*/ _rules = [...]func() bool{ nil, /* 0 root <- <(colors / ignore / text)*> */ func() bool { { position1 := position l2: { position3, tokenIndex3 := position, tokenIndex { position4, tokenIndex4 := position, tokenIndex if !_rules[rulecolors]() { goto l5 } goto l4 l5: position, tokenIndex = position4, tokenIndex4 if !_rules[ruleignore]() { goto l6 } goto l4 l6: position, tokenIndex = position4, tokenIndex4 if !_rules[ruletext]() { goto l3 } } l4: goto l2 l3: position, tokenIndex = position3, tokenIndex3 } add(ruleroot, position1) } return true }, /* 1 ignore <- <((prefix number? non_color_suffix) / escape_sequence)> */ func() bool { position7, tokenIndex7 := position, tokenIndex { position8 := position { position9, tokenIndex9 := position, tokenIndex if !_rules[ruleprefix]() { goto l10 } { position11, tokenIndex11 := position, tokenIndex if !_rules[rulenumber]() { goto l11 } goto l12 l11: position, tokenIndex = position11, tokenIndex11 } l12: if !_rules[rulenon_color_suffix]() { goto l10 } goto l9 l10: position, tokenIndex = position9, tokenIndex9 if !_rules[ruleescape_sequence]() { goto l7 } } l9: add(ruleignore, position8) } return true l7: position, tokenIndex = position7, tokenIndex7 return false }, /* 2 colors <- <((prefix color_suffix Action0) / (prefix color (delimiter color)* color_suffix))> */ func() bool { position13, tokenIndex13 := position, tokenIndex { position14 := position { position15, tokenIndex15 := position, tokenIndex if !_rules[ruleprefix]() { goto l16 } if !_rules[rulecolor_suffix]() { goto l16 } if !_rules[ruleAction0]() { goto l16 } goto l15 l16: position, tokenIndex = position15, tokenIndex15 if !_rules[ruleprefix]() { goto l13 } if !_rules[rulecolor]() { goto l13 } l17: { position18, tokenIndex18 := position, tokenIndex if !_rules[ruledelimiter]() { goto l18 } if !_rules[rulecolor]() { goto l18 } goto l17 l18: position, tokenIndex = position18, tokenIndex18 } if !_rules[rulecolor_suffix]() { goto l13 } } l15: add(rulecolors, position14) } return true l13: position, tokenIndex = position13, tokenIndex13 return false }, /* 3 text <- <(<(!'\x1b' .)+> Action1)> */ func() bool { position19, tokenIndex19 := position, tokenIndex { position20 := position { position21 := position { position24, tokenIndex24 := position, tokenIndex if buffer[position] != rune('\x1b') { goto l24 } position++ goto l19 l24: position, tokenIndex = position24, tokenIndex24 } if !matchDot() { goto l19 } l22: { position23, tokenIndex23 := position, tokenIndex { position25, tokenIndex25 := position, tokenIndex if buffer[position] != rune('\x1b') { goto l25 } position++ goto l23 l25: position, tokenIndex = position25, tokenIndex25 } if !matchDot() { goto l23 } goto l22 l23: position, tokenIndex = position23, tokenIndex23 } add(rulePegText, position21) } if !_rules[ruleAction1]() { goto l19 } add(ruletext, position20) } return true l19: position, tokenIndex = position19, tokenIndex19 return false }, /* 4 color <- <(standard_color / extended_color / text_attributes)> */ func() bool { position26, tokenIndex26 := position, tokenIndex { position27 := position { position28, tokenIndex28 := position, tokenIndex if !_rules[rulestandard_color]() { goto l29 } goto l28 l29: position, tokenIndex = position28, tokenIndex28 if !_rules[ruleextended_color]() { goto l30 } goto l28 l30: position, tokenIndex = position28, tokenIndex28 if !_rules[ruletext_attributes]() { goto l26 } } l28: add(rulecolor, position27) } return true l26: position, tokenIndex = position26, tokenIndex26 return false }, /* 5 standard_color <- <((zero <(('3' / '4' / '9' / ('1' '0')) [0-7])> Action2) / (zero <(('3' / '9') '9')> Action3) / (zero <(('4' / ('1' '0')) '9')> Action4))> */ func() bool { position31, tokenIndex31 := position, tokenIndex { position32 := position { position33, tokenIndex33 := position, tokenIndex if !_rules[rulezero]() { goto l34 } { position35 := position { position36, tokenIndex36 := position, tokenIndex if buffer[position] != rune('3') { goto l37 } position++ goto l36 l37: position, tokenIndex = position36, tokenIndex36 if buffer[position] != rune('4') { goto l38 } position++ goto l36 l38: position, tokenIndex = position36, tokenIndex36 if buffer[position] != rune('9') { goto l39 } position++ goto l36 l39: position, tokenIndex = position36, tokenIndex36 if buffer[position] != rune('1') { goto l34 } position++ if buffer[position] != rune('0') { goto l34 } position++ } l36: if c := buffer[position]; c < rune('0') || c > rune('7') { goto l34 } position++ add(rulePegText, position35) } if !_rules[ruleAction2]() { goto l34 } goto l33 l34: position, tokenIndex = position33, tokenIndex33 if !_rules[rulezero]() { goto l40 } { position41 := position { position42, tokenIndex42 := position, tokenIndex if buffer[position] != rune('3') { goto l43 } position++ goto l42 l43: position, tokenIndex = position42, tokenIndex42 if buffer[position] != rune('9') { goto l40 } position++ } l42: if buffer[position] != rune('9') { goto l40 } position++ add(rulePegText, position41) } if !_rules[ruleAction3]() { goto l40 } goto l33 l40: position, tokenIndex = position33, tokenIndex33 if !_rules[rulezero]() { goto l31 } { position44 := position { position45, tokenIndex45 := position, tokenIndex if buffer[position] != rune('4') { goto l46 } position++ goto l45 l46: position, tokenIndex = position45, tokenIndex45 if buffer[position] != rune('1') { goto l31 } position++ if buffer[position] != rune('0') { goto l31 } position++ } l45: if buffer[position] != rune('9') { goto l31 } position++ add(rulePegText, position44) } if !_rules[ruleAction4]() { goto l31 } } l33: add(rulestandard_color, position32) } return true l31: position, tokenIndex = position31, tokenIndex31 return false }, /* 6 extended_color <- <(extended_color_256 / extended_color_rgb)> */ func() bool { position47, tokenIndex47 := position, tokenIndex { position48 := position { position49, tokenIndex49 := position, tokenIndex if !_rules[ruleextended_color_256]() { goto l50 } goto l49 l50: position, tokenIndex = position49, tokenIndex49 if !_rules[ruleextended_color_rgb]() { goto l47 } } l49: add(ruleextended_color, position48) } return true l47: position, tokenIndex = position47, tokenIndex47 return false }, /* 7 extended_color_256 <- <(extended_color_prefix delimiter zero '5' delimiter Action5)> */ func() bool { position51, tokenIndex51 := position, tokenIndex { position52 := position if !_rules[ruleextended_color_prefix]() { goto l51 } if !_rules[ruledelimiter]() { goto l51 } if !_rules[rulezero]() { goto l51 } if buffer[position] != rune('5') { goto l51 } position++ if !_rules[ruledelimiter]() { goto l51 } { position53 := position if !_rules[rulenumber]() { goto l51 } add(rulePegText, position53) } if !_rules[ruleAction5]() { goto l51 } add(ruleextended_color_256, position52) } return true l51: position, tokenIndex = position51, tokenIndex51 return false }, /* 8 extended_color_rgb <- <(extended_color_prefix delimiter zero '2' delimiter Action6 delimiter Action7 delimiter Action8)> */ func() bool { position54, tokenIndex54 := position, tokenIndex { position55 := position if !_rules[ruleextended_color_prefix]() { goto l54 } if !_rules[ruledelimiter]() { goto l54 } if !_rules[rulezero]() { goto l54 } if buffer[position] != rune('2') { goto l54 } position++ if !_rules[ruledelimiter]() { goto l54 } { position56 := position if !_rules[rulenumber]() { goto l54 } add(rulePegText, position56) } if !_rules[ruleAction6]() { goto l54 } if !_rules[ruledelimiter]() { goto l54 } { position57 := position if !_rules[rulenumber]() { goto l54 } add(rulePegText, position57) } if !_rules[ruleAction7]() { goto l54 } if !_rules[ruledelimiter]() { goto l54 } { position58 := position if !_rules[rulenumber]() { goto l54 } add(rulePegText, position58) } if !_rules[ruleAction8]() { goto l54 } add(ruleextended_color_rgb, position55) } return true l54: position, tokenIndex = position54, tokenIndex54 return false }, /* 9 extended_color_prefix <- <(zero <(('3' / '4') '8')> Action9)> */ func() bool { position59, tokenIndex59 := position, tokenIndex { position60 := position if !_rules[rulezero]() { goto l59 } { position61 := position { position62, tokenIndex62 := position, tokenIndex if buffer[position] != rune('3') { goto l63 } position++ goto l62 l63: position, tokenIndex = position62, tokenIndex62 if buffer[position] != rune('4') { goto l59 } position++ } l62: if buffer[position] != rune('8') { goto l59 } position++ add(rulePegText, position61) } if !_rules[ruleAction9]() { goto l59 } add(ruleextended_color_prefix, position60) } return true l59: position, tokenIndex = position59, tokenIndex59 return false }, /* 10 text_attributes <- <(('0' Action10) / ('7' Action11) / ('1' / '4' / '5' / '8'))+> */ func() bool { position64, tokenIndex64 := position, tokenIndex { position65 := position { position68, tokenIndex68 := position, tokenIndex if buffer[position] != rune('0') { goto l69 } position++ if !_rules[ruleAction10]() { goto l69 } goto l68 l69: position, tokenIndex = position68, tokenIndex68 if buffer[position] != rune('7') { goto l70 } position++ if !_rules[ruleAction11]() { goto l70 } goto l68 l70: position, tokenIndex = position68, tokenIndex68 { position71, tokenIndex71 := position, tokenIndex if buffer[position] != rune('1') { goto l72 } position++ goto l71 l72: position, tokenIndex = position71, tokenIndex71 if buffer[position] != rune('4') { goto l73 } position++ goto l71 l73: position, tokenIndex = position71, tokenIndex71 if buffer[position] != rune('5') { goto l74 } position++ goto l71 l74: position, tokenIndex = position71, tokenIndex71 if buffer[position] != rune('8') { goto l64 } position++ } l71: } l68: l66: { position67, tokenIndex67 := position, tokenIndex { position75, tokenIndex75 := position, tokenIndex if buffer[position] != rune('0') { goto l76 } position++ if !_rules[ruleAction10]() { goto l76 } goto l75 l76: position, tokenIndex = position75, tokenIndex75 if buffer[position] != rune('7') { goto l77 } position++ if !_rules[ruleAction11]() { goto l77 } goto l75 l77: position, tokenIndex = position75, tokenIndex75 { position78, tokenIndex78 := position, tokenIndex if buffer[position] != rune('1') { goto l79 } position++ goto l78 l79: position, tokenIndex = position78, tokenIndex78 if buffer[position] != rune('4') { goto l80 } position++ goto l78 l80: position, tokenIndex = position78, tokenIndex78 if buffer[position] != rune('5') { goto l81 } position++ goto l78 l81: position, tokenIndex = position78, tokenIndex78 if buffer[position] != rune('8') { goto l67 } position++ } l78: } l75: goto l66 l67: position, tokenIndex = position67, tokenIndex67 } add(ruletext_attributes, position65) } return true l64: position, tokenIndex = position64, tokenIndex64 return false }, /* 11 zero <- <'0'*> */ func() bool { { position83 := position l84: { position85, tokenIndex85 := position, tokenIndex if buffer[position] != rune('0') { goto l85 } position++ goto l84 l85: position, tokenIndex = position85, tokenIndex85 } add(rulezero, position83) } return true }, /* 12 number <- <[0-9]+> */ func() bool { position86, tokenIndex86 := position, tokenIndex { position87 := position if c := buffer[position]; c < rune('0') || c > rune('9') { goto l86 } position++ l88: { position89, tokenIndex89 := position, tokenIndex if c := buffer[position]; c < rune('0') || c > rune('9') { goto l89 } position++ goto l88 l89: position, tokenIndex = position89, tokenIndex89 } add(rulenumber, position87) } return true l86: position, tokenIndex = position86, tokenIndex86 return false }, /* 13 prefix <- <(escape_sequence '[')> */ func() bool { position90, tokenIndex90 := position, tokenIndex { position91 := position if !_rules[ruleescape_sequence]() { goto l90 } if buffer[position] != rune('[') { goto l90 } position++ add(ruleprefix, position91) } return true l90: position, tokenIndex = position90, tokenIndex90 return false }, /* 14 escape_sequence <- <'\x1b'> */ func() bool { position92, tokenIndex92 := position, tokenIndex { position93 := position if buffer[position] != rune('\x1b') { goto l92 } position++ add(ruleescape_sequence, position93) } return true l92: position, tokenIndex = position92, tokenIndex92 return false }, /* 15 color_suffix <- <'m'> */ func() bool { position94, tokenIndex94 := position, tokenIndex { position95 := position if buffer[position] != rune('m') { goto l94 } position++ add(rulecolor_suffix, position95) } return true l94: position, tokenIndex = position94, tokenIndex94 return false }, /* 16 non_color_suffix <- <([A-H] / 'f' / 'S' / 'T' / 'J' / 'K')> */ func() bool { position96, tokenIndex96 := position, tokenIndex { position97 := position { position98, tokenIndex98 := position, tokenIndex if c := buffer[position]; c < rune('A') || c > rune('H') { goto l99 } position++ goto l98 l99: position, tokenIndex = position98, tokenIndex98 if buffer[position] != rune('f') { goto l100 } position++ goto l98 l100: position, tokenIndex = position98, tokenIndex98 if buffer[position] != rune('S') { goto l101 } position++ goto l98 l101: position, tokenIndex = position98, tokenIndex98 if buffer[position] != rune('T') { goto l102 } position++ goto l98 l102: position, tokenIndex = position98, tokenIndex98 if buffer[position] != rune('J') { goto l103 } position++ goto l98 l103: position, tokenIndex = position98, tokenIndex98 if buffer[position] != rune('K') { goto l96 } position++ } l98: add(rulenon_color_suffix, position97) } return true l96: position, tokenIndex = position96, tokenIndex96 return false }, /* 17 delimiter <- <';'> */ func() bool { position104, tokenIndex104 := position, tokenIndex { position105 := position if buffer[position] != rune(';') { goto l104 } position++ add(ruledelimiter, position105) } return true l104: position, tokenIndex = position104, tokenIndex104 return false }, /* 19 Action0 <- <{ p.pushResetColor() }> */ func() bool { { add(ruleAction0, position) } return true }, nil, /* 21 Action1 <- <{ p.pushText(text) }> */ func() bool { { add(ruleAction1, position) } return true }, /* 22 Action2 <- <{ p.pushStandardColorWithCategory(text) }> */ func() bool { { add(ruleAction2, position) } return true }, /* 23 Action3 <- <{ p.pushResetForegroundColor() }> */ func() bool { { add(ruleAction3, position) } return true }, /* 24 Action4 <- <{ p.pushResetBackgroundColor() }> */ func() bool { { add(ruleAction4, position) } return true }, /* 25 Action5 <- <{ p.setExtendedColor256(text) }> */ func() bool { { add(ruleAction5, position) } return true }, /* 26 Action6 <- <{ p.setExtendedColorR(text) }> */ func() bool { { add(ruleAction6, position) } return true }, /* 27 Action7 <- <{ p.setExtendedColorG(text) }> */ func() bool { { add(ruleAction7, position) } return true }, /* 28 Action8 <- <{ p.setExtendedColorB(text) }> */ func() bool { { add(ruleAction8, position) } return true }, /* 29 Action9 <- <{ p.pushExtendedColor(text) }> */ func() bool { { add(ruleAction9, position) } return true }, /* 30 Action10 <- <{ p.pushResetColor() }> */ func() bool { { add(ruleAction10, position) } return true }, /* 31 Action11 <- <{ p.pushReverseColor() }> */ func() bool { { add(ruleAction11, position) } return true }, } p.rules = _rules return nil } ================================================ FILE: parser/parser.go ================================================ package parser import ( "strconv" "github.com/jiro4989/textimg/v3/color" "github.com/jiro4989/textimg/v3/token" ) type ParserFunc struct { // pegが生成するTokensと名前が衝突するので別名にする Tk token.Tokens } func Parse(s string) (token.Tokens, error) { p := &Parser{Buffer: s} if err := p.Init(); err != nil { return nil, err } if err := p.Parse(); err != nil { return nil, err } p.Execute() return p.Tk, nil } func (p *ParserFunc) pushResetColor() { p.Tk = append(p.Tk, token.NewResetColor()) } func (p *ParserFunc) pushResetForegroundColor() { p.Tk = append(p.Tk, token.NewResetForegroundColor()) } func (p *ParserFunc) pushResetBackgroundColor() { p.Tk = append(p.Tk, token.NewResetBackgroundColor()) } func (p *ParserFunc) pushReverseColor() { p.Tk = append(p.Tk, token.NewReverseColor()) } func (p *ParserFunc) pushText(text string) { p.Tk = append(p.Tk, token.NewText(text)) } func (p *ParserFunc) pushStandardColorWithCategory(text string) { p.Tk = append(p.Tk, token.NewStandardColorWithCategory(text)) } func (p *ParserFunc) pushExtendedColor(text string) { p.Tk = append(p.Tk, token.NewExtendedColor(text)) } func (p *ParserFunc) setExtendedColor256(text string) { n, _ := strconv.ParseUint(text, 10, 8) p.Tk[len(p.Tk)-1].Color = color.Map256[int(n)] } func (p *ParserFunc) setExtendedColorR(text string) { n, _ := strconv.ParseUint(text, 10, 8) p.Tk[len(p.Tk)-1].Color.R = uint8(n) } func (p *ParserFunc) setExtendedColorG(text string) { n, _ := strconv.ParseUint(text, 10, 8) p.Tk[len(p.Tk)-1].Color.G = uint8(n) } func (p *ParserFunc) setExtendedColorB(text string) { n, _ := strconv.ParseUint(text, 10, 8) p.Tk[len(p.Tk)-1].Color.B = uint8(n) } ================================================ FILE: parser/parser_test.go ================================================ package parser import ( "testing" "github.com/jiro4989/textimg/v3/color" "github.com/jiro4989/textimg/v3/token" "github.com/stretchr/testify/assert" ) func TestParse(t *testing.T) { tests := []struct { desc string s string want token.Tokens wantErr bool }{ { desc: "正常系: 黒", s: "\x1b[30m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBABlack, }, }, wantErr: false, }, { desc: "正常系: 90系と100系", s: "\x1b[90;100m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBADarkGray, }, { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBADarkGray, }, }, wantErr: false, }, { desc: "正常系: 黒赤緑", s: "\x1b[30m\x1b[31m\x1b[32m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBABlack, }, { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBARed, }, { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBAGreen, }, }, wantErr: false, }, { desc: "正常系: 赤とテキストとリセット", s: "\x1b[31m\n hello\tworld \n\x1b[0m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBARed, }, { Kind: token.KindText, Text: "\n hello\tworld \n", }, { Kind: token.KindColor, ColorType: token.ColorTypeReset, }, }, wantErr: false, }, { desc: "正常系: 39はResetForeground, 49はResetBackground", s: "\x1b[39mReset\x1b[49mReset", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeResetForeground, }, { Kind: token.KindText, Text: "Reset", }, { Kind: token.KindColor, ColorType: token.ColorTypeResetBackground, }, { Kind: token.KindText, Text: "Reset", }, }, wantErr: false, }, { desc: "正常系: 前景色と背景色の同時指定 + リセット省略系", s: "\x1b[32;43mhello world\x1b[m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBAGreen, }, { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBAYellow, }, { Kind: token.KindText, Text: "hello world", }, { Kind: token.KindColor, ColorType: token.ColorTypeReset, }, }, wantErr: false, }, { desc: "正常系: 0埋めありの指定", s: "\x1b[032;00043mhello world", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBAGreen, }, { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBAYellow, }, { Kind: token.KindText, Text: "hello world", }, }, wantErr: false, }, { desc: "正常系: 拡張系 256色", s: "\x1b[38;5;1m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.Map256[1], }, }, wantErr: false, }, { desc: "正常系: 拡張系 RGB指定", s: "\x1b[48;2;1;2;3m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBA{ R: 1, G: 2, B: 3, A: 255, }, }, }, wantErr: false, }, { desc: "正常系: 拡張系の混在", s: "\x1b[38;5;2;48;2;1;2;3mこんばんは", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.Map256[2], }, { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBA{ R: 1, G: 2, B: 3, A: 255, }, }, { Kind: token.KindText, Text: "こんばんは", }, }, wantErr: false, }, { desc: "正常系: 拡張系の混在 + 0埋め", s: "\x1b[038;005;002;048;002;001;002;003mx1bこんば\nんはx1b", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.Map256[2], }, { Kind: token.KindColor, ColorType: token.ColorTypeBackground, Color: color.RGBA{ R: 1, G: 2, B: 3, A: 255, }, }, { Kind: token.KindText, Text: "x1bこんば\nんはx1b", }, }, wantErr: false, }, { desc: "正常系: 関係ないエスケープシーケンス系無視される", s: "\x1b[1A\x1b[A\x1b[K寿司", want: token.Tokens{ { Kind: token.KindText, Text: "寿司", }, }, wantErr: false, }, { desc: "正常系: 空文字列の場合は空", s: "", want: nil, wantErr: false, }, { desc: "正常系: 不完全なANSIエスケープシーケンスは無視", s: "\x1b[31helloworld\x1b[30m", want: token.Tokens{ { Kind: token.KindText, Text: "[31helloworld", }, { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBABlack, }, }, wantErr: false, }, { desc: "正常系: 範囲外のエスケープシーケンスの場合は無視", s: "\x1b[310mhelloworld", want: token.Tokens{ { Kind: token.KindText, Text: "[310mhelloworld", }, }, wantErr: false, }, { desc: "正常系: text_attributes", s: "\x1b[01;31mRED", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeReset, }, { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.RGBARed, }, { Kind: token.KindText, Text: "RED", }, }, wantErr: false, }, { desc: "異常系: 拡張系 256色で数値がuint8を超えた場合はMap256の最後の値が設定される", s: "\x1b[38;5;256m", want: token.Tokens{ { Kind: token.KindColor, ColorType: token.ColorTypeForeground, Color: color.Map256[255], }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) got, err := Parse(tt.s) if tt.wantErr { assert.Error(err) assert.Nil(got) return } assert.Equal(tt.want, got) }) } } ================================================ FILE: root.go ================================================ package main import ( "image/color" "runtime" "strings" "github.com/jiro4989/textimg/v3/config" "github.com/jiro4989/textimg/v3/image" "github.com/jiro4989/textimg/v3/internal/global" "github.com/jiro4989/textimg/v3/parser" "github.com/spf13/cobra" ) var ( conf config.Config envvars config.EnvVars ) func init() { envvars = config.NewEnvVars() cobra.OnInitialize() RootCommand.Flags().SortFlags = false RootCommand.Flags().StringVarP(&conf.Foreground, "foreground", "g", "white", `foreground text color. available color types are [black|red|green|yellow|blue|magenta|cyan|white] or (R,G,B,A(0~255))`) RootCommand.Flags().StringVarP(&conf.Background, "background", "b", "black", `background text color. color types are same as "foreground" option`) var font string envFontFile := envvars.FontFile if envFontFile != "" { font = envFontFile } RootCommand.Flags().StringVarP(&conf.FontFile, "fontfile", "f", font, `font file path. You can change this default value with environment variables TEXTIMG_FONT_FILE`) RootCommand.Flags().IntVarP(&conf.FontIndex, "fontindex", "x", 0, "") conf.SetFontFileAndFontIndex(runtime.GOOS) envEmojiFontFile := envvars.EmojiFontFile RootCommand.Flags().StringVarP(&conf.EmojiFontFile, "emoji-fontfile", "e", envEmojiFontFile, "emoji font file") RootCommand.Flags().IntVarP(&conf.EmojiFontIndex, "emoji-fontindex", "X", 0, "") RootCommand.Flags().BoolVarP(&conf.UseEmojiFont, "use-emoji-font", "i", false, "use emoji font") RootCommand.Flags().BoolVarP(&conf.UseShellgeiEmojiFontfile, "shellgei-emoji-fontfile", "z", false, `emoji font file for shellgei-bot (path: "`+config.ShellgeiEmojiFontPath+`")`) RootCommand.Flags().IntVarP(&conf.FontSize, "fontsize", "F", 20, "font size") RootCommand.Flags().StringVarP(&conf.Outpath, "out", "o", "", `output image file path. available image formats are [png | jpg | gif]`) RootCommand.Flags().BoolVarP(&conf.AddTimeStamp, "timestamp", "t", false, `add time stamp to output image file path.`) RootCommand.Flags().BoolVarP(&conf.SaveNumberedFile, "numbered", "n", false, `add number-suffix to filename when the output file was existed. ex: t_2.png`) RootCommand.Flags().BoolVarP(&conf.UseShellgeiImagedir, "shellgei-imagedir", "s", false, `image directory path (path: "$HOME/Pictures/t.png" or "$TEXTIMG_OUTPUT_DIR/t.png")`) RootCommand.Flags().BoolVarP(&conf.UseAnimation, "animation", "a", false, "generate animation gif") RootCommand.Flags().IntVarP(&conf.Delay, "delay", "d", 20, "animation delay time") RootCommand.Flags().IntVarP(&conf.LineCount, "line-count", "l", 1, "animation input line count") RootCommand.Flags().BoolVarP(&conf.UseSlideAnimation, "slide", "S", false, "use slide animation") RootCommand.Flags().IntVarP(&conf.SlideWidth, "slide-width", "W", 1, "sliding animation width") RootCommand.Flags().BoolVarP(&conf.SlideForever, "forever", "E", false, "sliding forever") RootCommand.Flags().BoolVarP(&conf.PrintEnvironments, "environments", "", false, "print environment variables") RootCommand.Flags().BoolVarP(&conf.ToSlackIcon, "slack", "", false, "resize to slack icon size (128x128 px)") RootCommand.Flags().IntVarP(&conf.ResizeWidth, "resize-width", "", 0, "resize width") RootCommand.Flags().IntVarP(&conf.ResizeHeight, "resize-height", "", 0, "resize height") } var RootCommand = &cobra.Command{ Use: global.AppName, Short: global.AppName + " is command to convert from colored text (ANSI or 256) to image.", Example: global.AppName + ` $'\x1b[31mRED\x1b[0m' -o out.png`, Version: global.Version, RunE: func(cmd *cobra.Command, args []string) error { return RunRootCommand(conf, args, envvars) }, } func RunRootCommand(c config.Config, args []string, envs config.EnvVars) error { if c.PrintEnvironments { config.PrintEnvs() return nil } if err := c.Adjust(args, envs); err != nil { return err } defer c.Writer.Close() tokens, err := parser.Parse(strings.Join(c.Texts, "\n")) if err != nil { return err } bw := tokens.MaxStringWidth() bh := len(tokens.StringLines()) if !c.ToSlackIcon { // TODO: コピペコードになってるので共通化する var ( f = c.FontSize charWidth = f / 2 charHeight = int(float64(f) * 1.1) w = bw * charWidth h = bh * charHeight ) c.ResizeWidth, c.ResizeHeight = complementWidthHeight(w, h, c.ResizeWidth, c.ResizeHeight) } param := &image.ImageParam{ BaseWidth: bw, BaseHeight: bh, ForegroundColor: color.RGBA(c.ForegroundColor), BackgroundColor: color.RGBA(c.BackgroundColor), FontFace: c.FontFace, EmojiFontFace: c.EmojiFontFace, EmojiDir: c.EmojiDir, FontSize: c.FontSize, Delay: c.Delay, UseAnimation: c.UseAnimation, AnimationLineCount: c.LineCount, ResizeWidth: c.ResizeWidth, ResizeHeight: c.ResizeHeight, UseEmoji: c.UseEmojiFont, } img := image.NewImage(param) if err := img.Draw(tokens); err != nil { return err } if err := img.Encode(c.Writer, c.FileExtension); err != nil { return err } return nil } // complementWidthHeight は width, height の片方が 0 の時、サイズを調整する。 func complementWidthHeight(x, y, w, h int) (int, int) { if w == 0 { hh := y d := float64(h) / float64(hh) w = int(float64(x) * d) return w, h } if h == 0 { ww := x d := float64(w) / float64(ww) h = int(float64(y) * d) return w, h } return w, h } ================================================ FILE: root_common_test.go ================================================ package main import "github.com/jiro4989/textimg/v3/config" func newDefaultConfig() config.Config { return config.Config{ Foreground: "white", Background: "black", Outpath: "", AddTimeStamp: false, SaveNumberedFile: false, FontFile: "", FontIndex: 0, EmojiFontFile: "", EmojiFontIndex: 0, UseEmojiFont: false, FontSize: 20, UseAnimation: false, Delay: 20, LineCount: 1, UseSlideAnimation: false, SlideWidth: 1, SlideForever: false, ToSlackIcon: false, PrintEnvironments: false, UseShellgeiImagedir: false, UseShellgeiEmojiFontfile: false, ResizeWidth: 0, ResizeHeight: 0, Writer: config.NewMockWriter(false, false), } } ================================================ FILE: root_on_docker_test.go ================================================ //go:build docker // // 日本語や絵文字が使えるDocker環境上で実行する想定のテスト。 // どうしてもDocker上でしかテストできないもののみこのファイルに記述する。 package main import ( "os" "testing" "github.com/jiro4989/textimg/v3/config" "github.com/stretchr/testify/assert" ) func TestRunRootCommandOnDocker(t *testing.T) { var ( outDockerDir = "testdata/out_docker" fontFile = "/tmp/MyricaM.TTC" emojiDir = "/usr/local/src/noto-emoji/png/128" emojiFontFile = "/tmp/Symbola_hint.ttf" ) // nolint os.Mkdir(outDockerDir, os.ModePerm) tests := []struct { desc string c config.Config args []string envs config.EnvVars wantErr bool existsFile string }{ { desc: "正常系: 日本語や絵文字を描画できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDockerDir + "/root_on_docker_test_japanese.png" c.Writer = nil c.FontFile = fontFile // c.EmojiDir = emojiDir c.EmojiFontFile = emojiFontFile return c }(), args: []string{"\x1b[31mあいうえお\n\x1b[32;43mあ😃a👍!👀ん👄"}, envs: config.EnvVars{ EmojiDir: emojiDir, }, wantErr: false, existsFile: outDockerDir + "/root_on_docker_test_japanese.png", }, { desc: "正常系: 絵文字を連続して描画しても背景色が絵文字を上書きしない", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDockerDir + "/root_on_docker_test_emoji.png" c.Writer = nil c.FontFile = fontFile // c.EmojiDir = emojiDir c.EmojiFontFile = emojiFontFile return c }(), args: []string{"😃👍👀👄"}, envs: config.EnvVars{ EmojiDir: emojiDir, }, wantErr: false, existsFile: outDockerDir + "/root_on_docker_test_emoji.png", }, { desc: "正常系: 特殊な絵文字を使う", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDockerDir + "/root_on_docker_test_shellgei_emoji.png" c.Writer = nil c.UseEmojiFont = true c.FontFile = fontFile // c.EmojiDir = emojiDir c.EmojiFontFile = emojiFontFile return c }(), args: []string{"\x1b[31mあいうえお\n\x1b[32;43mあ😃a👍!👀ん👄"}, envs: config.EnvVars{ EmojiDir: emojiDir, }, wantErr: false, existsFile: outDockerDir + "/root_on_docker_test_shellgei_emoji.png", }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) err := RunRootCommand(tt.c, tt.args, tt.envs) if tt.wantErr { assert.Error(err) return } assert.NoError(err) if tt.existsFile != "" { _, err := os.Stat(tt.existsFile) got := os.IsNotExist(err) assert.False(got) } }) } } ================================================ FILE: root_test.go ================================================ //go:build !docker package main import ( "os" "testing" "github.com/jiro4989/textimg/v3/config" "github.com/stretchr/testify/assert" ) func TestRunRootCommand(t *testing.T) { b, _ := os.ReadFile(inDir + "/red_grad.txt") grad := string(b) b, _ = os.ReadFile(inDir + "/255.txt") c255 := string(b) tests := []struct { desc string c config.Config args []string envs config.EnvVars wantErr bool existsFile string }{ { desc: "正常系: PrintEnvironmentsが設定されていると環境変数を出力して終了", c: config.Config{ PrintEnvironments: true, }, args: []string{"hello"}, envs: config.EnvVars{}, wantErr: false, }, { desc: "正常系: 正常系がパスする。出力先はモックWriterなのでファイルは生成されない", c: func() config.Config { c := newDefaultConfig() c.Outpath = "t.png" return c }(), args: []string{"hello"}, envs: config.EnvVars{}, wantErr: false, }, { desc: "異常系: Writerがエラーを返す", c: func() config.Config { c := newDefaultConfig() c.Outpath = "t.png" c.Writer = config.NewMockWriter(true, false) return c }(), args: []string{"hello"}, envs: config.EnvVars{}, wantErr: true, }, // 旧 main_test.go を移行してきたもの { desc: "正常系: 画像ファイルに出力する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_red_and_background_is_black.png" c.Writer = nil return c }(), args: []string{"1234\x1b[31mred\x1b[m5678\nabcd\x1b[32mgreen\x1b[0mefgh\nあい\x1b[33mう\x1b[mえお"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_red_and_background_is_black.png", }, { desc: "正常系: Foregroundのみもとにもどす、Backgroundのみもとに戻す", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_foreground_default_background_default.png" c.Writer = nil return c }(), args: []string{"\x1b[31;42mRedGreen\x1b[39mRedGreen\x1b[49mRedGreen"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_foreground_default_background_default.png", }, { desc: "正常系: 256色を使う", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_color_256.png" c.Writer = nil return c }(), args: []string{c255}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_color_256.png", }, { desc: "正常系: RGB色を使う", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_color_rgb.png" c.Writer = nil return c }(), args: []string{grad}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_color_rgb.png", }, { desc: "正常系: JPEGで出力する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_jpeg.jpeg" c.Writer = nil return c }(), args: []string{"jpeg"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_jpeg.jpeg", }, { desc: "正常系: GIFで出力する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_gif.gif" c.Writer = nil return c }(), args: []string{"gif"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_gif.gif", }, { desc: "正常系: 日本語と絵文字を描画する(ただし豆腐になる)。このテストはDockerの方で実施する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_tofu.png" c.Writer = nil return c }(), args: []string{"あいうえお👍"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_tofu.png", }, { desc: "正常系: 前景色と背景色を反転する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_reverse.png" c.Writer = nil return c }(), args: []string{"\x1b[31;42mRED\x1b[7m\nGREEN\x1b[0m"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_reverse.png", }, { desc: "正常系: 文字色と背景色を変更する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_green_and_background_is_blue.png" c.Writer = nil c.Foreground = "green" c.Background = "blue" return c }(), args: []string{"green"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_green_and_background_is_blue.png", }, { desc: "正常系: カンマ区切りで指定", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_blue_and_background_is_red.png" c.Writer = nil c.Foreground = "0,0,255,255" c.Background = "255,0,0,255" return c }(), args: []string{"blue"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_blue_and_background_is_red.png", }, { desc: "正常系: Slackアイコンサイズで生成する", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_blue_and_background_is_red_slack_icon_size.png" c.Writer = nil c.Foreground = "0,0,255,255" c.Background = "255,0,0,255" c.ToSlackIcon = true return c }(), args: []string{"slack"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_blue_and_background_is_red_slack_icon_size.png", }, { desc: "正常系: 明示的に幅を指定できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_blue_and_background_is_red_100x200.png" c.Writer = nil c.Foreground = "0,0,255,255" c.Background = "255,0,0,255" c.ResizeWidth = 100 c.ResizeHeight = 200 return c }(), args: []string{"100x200"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_blue_and_background_is_red_100x200.png", }, { desc: "正常系: Widthのみを指定した場合はHeightが調整される", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_blue_and_background_is_red_100w.png" c.Writer = nil c.Foreground = "0,0,255,255" c.Background = "255,0,0,255" c.ResizeWidth = 100 return c }(), args: []string{"100w"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_blue_and_background_is_red_100w.png", }, { desc: "正常系: Heightのみを指定した場合はWidthが調整される", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_font_is_blue_and_background_is_red_100h.png" c.Writer = nil c.Foreground = "0,0,255,255" c.Background = "255,0,0,255" c.ResizeHeight = 100 return c }(), args: []string{"100h"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_font_is_blue_and_background_is_red_100h.png", }, { desc: "正常系: 1行のアニメを生成できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_1_line.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 1 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_1_line.gif", }, { desc: "正常系: 2行のアニメを生成できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_2_line.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 2 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_2_line.gif", }, { desc: "正常系: 4行のアニメを生成できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_4_line.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 4 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4\n\x1b[31m5\n\x1b[32m6\n\x1b[33m7\n\x1b[34m8"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_4_line.gif", }, { desc: "正常系: 8行のアニメを生成できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_8_line.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 8 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4\n\x1b[31m5\n\x1b[32m6\n\x1b[33m7\n\x1b[34m8"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_8_line.gif", }, { desc: "正常系: 4行のアニメを2行ずつスライドする", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_4_line_slide_2_forever.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 4 c.UseSlideAnimation = true c.SlideWidth = 2 c.SlideForever = true c.Delay = 100 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4\n\x1b[31m5\n\x1b[32m6\n\x1b[33m7\n\x1b[34m8"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_4_line_slide_2_forever.gif", }, { desc: "正常系: 4行のアニメを3行ずつスライドする", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_animation_4_line_slide_3_forever.gif" c.Writer = nil c.UseAnimation = true c.LineCount = 4 c.UseSlideAnimation = true c.SlideWidth = 3 c.SlideForever = true c.Delay = 100 return c }(), args: []string{"\x1b[31m1\n\x1b[32m2\n\x1b[33m3\n\x1b[34m4\n\x1b[31m5\n\x1b[32m6\n\x1b[33m7\n\x1b[34m8"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_animation_4_line_slide_3_forever.gif", }, { desc: "正常系: SlackアイコンサイズでアニメーションGIFを生成できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_slack_icon_size_animation.gif" c.Writer = nil c.ToSlackIcon = true c.UseAnimation = true return c }(), args: []string{"1\n2\n3\n4"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_slack_icon_size_animation.gif", }, { desc: "正常系: すでに同名のファイルが存在する時、別名で保存される", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.SaveNumberedFile = true return c }(), args: []string{"number"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_numbering.png", }, { desc: "正常系: すでに同名のファイルが存在する時、別名で保存される_2", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.SaveNumberedFile = true return c }(), args: []string{"number"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_numbering_2.png", }, { desc: "正常系: すでに同名のファイルが存在する時、別名で保存される_3", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.SaveNumberedFile = true return c }(), args: []string{"number"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_numbering_3.png", }, { desc: "正常系: フォントインデックスを指定できる", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_index.png" c.Writer = nil c.FontIndex = 0 c.EmojiFontIndex = 0 return c }(), args: []string{"index"}, envs: config.EnvVars{}, wantErr: false, existsFile: outDir + "/root_test_index.png", }, { desc: "異常系: 空文字列は不正", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_empty_string.png" c.Writer = nil return c }(), args: []string{""}, envs: config.EnvVars{}, wantErr: true, }, { desc: "異常系: 改行文字のみは不正", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_only_line.png" c.Writer = nil return c }(), args: []string{"\n\n\n"}, envs: config.EnvVars{}, wantErr: true, }, { desc: "異常系: 色文字列が不正", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.Foreground = "ggg" return c }(), args: []string{"ggg"}, envs: config.EnvVars{}, wantErr: true, }, { desc: "異常系: 背景色が不正", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.Background = "ggg" return c }(), args: []string{"ggg"}, envs: config.EnvVars{}, wantErr: true, }, { desc: "異常系: 不正なフォント指定", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.FontFile = inDir + "/illegal_font.ttc" return c }(), args: []string{"ggg"}, envs: config.EnvVars{}, wantErr: true, }, { desc: "異常系: 不正な絵文字フォント指定", c: func() config.Config { c := newDefaultConfig() c.Outpath = outDir + "/root_test_numbering.png" c.Writer = nil c.EmojiFontFile = inDir + "/illegal_font.ttc" return c }(), args: []string{"ggg"}, envs: config.EnvVars{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) err := RunRootCommand(tt.c, tt.args, tt.envs) if tt.wantErr { assert.Error(err) return } assert.NoError(err) if tt.existsFile != "" { _, err := os.Stat(tt.existsFile) got := os.IsNotExist(err) assert.False(got) } }) } } func TestComplementWidthHeight(t *testing.T) { type TestData struct { desc string x, y, w, h int wantWidth int wantHeight int } tds := []TestData{ { desc: "正常系: wが0のときはwidthが調整される", x: 200, y: 100, w: 0, h: 200, wantWidth: 400, wantHeight: 200, }, { desc: "正常系: hが0のときはheightが調整される", x: 200, y: 100, w: 100, h: 0, wantWidth: 100, wantHeight: 50, }, { desc: "正常系: hが0のときはheightが調整される", x: 200, y: 100, w: 100, h: 0, wantWidth: 100, wantHeight: 50, }, { desc: "正常系: wとhが0出ないときはwとhが返る", x: 200, y: 100, w: 400, h: 300, wantWidth: 400, wantHeight: 300, }, } for _, v := range tds { t.Run(v.desc, func(t *testing.T) { a := assert.New(t) w, h := complementWidthHeight(v.x, v.y, v.w, v.h) a.Equal(v.wantWidth, w) a.Equal(v.wantHeight, h) }) } } ================================================ FILE: scripts/fetch_color.sh ================================================ #!/bin/bash curl "https://jonasjacek.github.io/colors/" \ | grep style \ | sed -r 's@.*([0-9]+).*(rgb[^<]+).*@\1:\2@g' \ | sed -re 's@rgb\(@color.RGBA{@g' -e 's/\)/,255},/g' ================================================ FILE: scripts/width/main.go ================================================ // width は文字のrunewidthが返す文字幅を確認するためのツール。 /* 使い方 cd tools/width go build . ./width あいうえお■漢字abcde😲 */ package main import ( "fmt" "os" "github.com/mattn/go-runewidth" ) func main() { args := os.Args fmt.Println("Char CodePoint Width") runewidth.DefaultCondition.StrictEmojiNeutral = false for _, c := range args[1] { text := fmt.Sprintf("%v %d %d", string(c), c, runewidth.RuneWidth(c)) fmt.Println(text) } } ================================================ FILE: testdata/in/255.txt ================================================ 000001002003004005006007008009010011012013014015 016017018019020021022023024025026027028029030031 032033034035036037038039040041042043044045046047 048049050051052053054055056057058059060061062063 064065066067068069070071072073074075076077078079 080081082083084085086087088089090091092093094095 096097098099100101102103104105106107108109110111 112113114115116117118119120121122123124125126127 128129130131132133134135136137138139140141142143 144145146147148149150151152153154155156157158159 160161162163164165166167168169170171172173174175 176177178179180181182183184185186187188189190191 192193194195196197198199200201202203204205206207 208209210211212213214215216217218219220221222223 224225226227228229230231232233234235236237238239 240241242243244245246247248249250251252253254255 ================================================ FILE: testdata/in/illegal_font.otc ================================================ 2 ================================================ FILE: testdata/in/illegal_font.ttc ================================================ 1 ================================================ FILE: testdata/in/illegal_font.txt ================================================ 3 ================================================ FILE: testdata/in/red_grad.txt ================================================ 000001002003004005006007008009010011012013014015 016017018019020021022023024025026027028029030031 032033034035036037038039040041042043044045046047 048049050051052053054055056057058059060061062063 064065066067068069070071072073074075076077078079 080081082083084085086087088089090091092093094095 096097098099100101102103104105106107108109110111 112113114115116117118119120121122123124125126127 128129130131132133134135136137138139140141142143 144145146147148149150151152153154155156157158159 160161162163164165166167168169170171172173174175 176177178179180181182183184185186187188189190191 192193194195196197198199200201202203204205206207 208209210211212213214215216217218219220221222223 224225226227228229230231232233234235236237238239 240241242243244245246247248249250251252253254255 ================================================ FILE: token/token.go ================================================ package token import ( "strconv" "strings" "github.com/jiro4989/textimg/v3/color" "github.com/mattn/go-runewidth" ) type ( Kind int ColorType int Token struct { Kind Kind ColorType ColorType Color color.RGBA Text string } Tokens []Token ) const ( KindEmpty Kind = iota KindText KindColor KindNotColor ColorTypeReset ColorType = iota // \x1b[0m 指定をリセット ColorTypeBold // \x1b[1m 太字 ColorTypeDim // \x1b[2m 薄く表示 ColorTypeItalic // \x1b[3m イタリック ColorTypeUnderline // \x1b[4m アンダーライン ColorTypeBlink // \x1b[5m ブリンク ColorTypeSpeedyBlink // \x1b[6m 高速ブリンク ColorTypeReverse // \x1b[7m 文字色と背景色の反転 ColorTypeHide // \x1b[8m 表示を隠す ColorTypeDelete // \x1b[9m 取り消し ColorTypeForeground ColorTypeBackground ColorTypeResetForeground ColorTypeResetBackground ) func init() { // Unicode Neutral で定義されている絵文字(例: 👁)を幅2として扱う runewidth.DefaultCondition.StrictEmojiNeutral = false } func NewResetColor() Token { return Token{ Kind: KindColor, ColorType: ColorTypeReset, } } func NewResetForegroundColor() Token { return Token{ Kind: KindColor, ColorType: ColorTypeResetForeground, } } func NewResetBackgroundColor() Token { return Token{ Kind: KindColor, ColorType: ColorTypeResetBackground, } } func NewReverseColor() Token { return Token{ Kind: KindColor, ColorType: ColorTypeReverse, } } func NewText(text string) Token { return Token{ Kind: KindText, Text: text, } } func NewStandardColorWithCategory(text string) Token { n, _ := strconv.Atoi(text) return Token{ Kind: KindColor, ColorType: colorType(n), Color: color.ANSIMap[n], } } func NewExtendedColor(text string) Token { n, _ := strconv.Atoi(text) return Token{ Kind: KindColor, ColorType: colorType(n), Color: color.RGBA{A: 255}, } } func colorType(n int) ColorType { var t ColorType switch n / 10 { case 3, 9: t = ColorTypeForeground case 4, 10: t = ColorTypeBackground } return t } func (t *Tokens) MaxStringWidth() int { var strs []string for _, tt := range *t { if tt.Kind != KindText { continue } s := tt.Text strs = append(strs, s) } s := strings.Join(strs, "") lines := strings.Split(s, "\n") var max int for _, line := range lines { w := runewidth.StringWidth(line) if max < w { max = w } } return max } func (t *Tokens) StringLines() []string { var strs []string for _, tt := range *t { if tt.Kind != KindText { continue } s := tt.Text strs = append(strs, s) } s := strings.Join(strs, "") lines := strings.Split(s, "\n") return lines } ================================================ FILE: token/token_test.go ================================================ package token import ( "testing" "github.com/jiro4989/textimg/v3/color" "github.com/stretchr/testify/assert" ) func TestToken_MaxStringWidth(t *testing.T) { tests := []struct { desc string t Tokens want int }{ { desc: "正常系: hello = 5", t: Tokens{ { Kind: KindText, Text: "hello", }, }, want: 5, }, { desc: "正常系: あいうえお = 10", t: Tokens{ { Kind: KindText, Text: "あいうえお", }, }, want: 10, }, { desc: "正常系: he\nllo = 3", t: Tokens{ { Kind: KindText, Text: "he\nllo", }, }, want: 3, }, { desc: "正常系: hel\nlo = 3", t: Tokens{ { Kind: KindText, Text: "hel\nlo", }, }, want: 3, }, { desc: "正常系: aREDb = 5", t: Tokens{ { Kind: KindText, Text: "a", }, { Kind: KindColor, ColorType: ColorTypeForeground, Color: color.RGBARed, }, { Kind: KindText, Text: "RED", }, { Kind: KindColor, ColorType: ColorTypeReset, }, { Kind: KindText, Text: "b", }, }, want: 5, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) got := tt.t.MaxStringWidth() assert.Equal(tt.want, got) }) } } func TestToken_StringLines(t *testing.T) { tests := []struct { desc string t Tokens want []string }{ { desc: "正常系: hello", t: Tokens{ { Kind: KindText, Text: "hello", }, }, want: []string{"hello"}, }, { desc: "正常系: hel\nlo\nworld", t: Tokens{ { Kind: KindText, Text: "hel\nlo\nworld", }, }, want: []string{"hel", "lo", "world"}, }, { desc: "正常系: aREDb = 5", t: Tokens{ { Kind: KindText, Text: "a", }, { Kind: KindColor, ColorType: ColorTypeForeground, Color: color.RGBARed, }, { Kind: KindText, Text: "RED", }, { Kind: KindColor, ColorType: ColorTypeReset, }, { Kind: KindText, Text: "b", }, }, want: []string{"aREDb"}, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { assert := assert.New(t) got := tt.t.StringLines() assert.Equal(tt.want, got) }) } }