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

[](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:
<!--ts-->
* [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)
<!-- Added by: jiro4989, at: Sat Jun 19 17:52:14 JST 2021 -->
<!--te-->
## 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
```

screenfetch:
```bash
screenfetch | textimg -o out.png
```
[bat](https://github.com/sharkdp/bat):
```bash
bat --color=always /etc/profile | textimg -o out.png
```

ccze:
```bash
ls -lah | ccze -A | textimg -o out.png
```

lolcat:
```bash
seq -f 'seq %g | xargs' 18 | bash | lolcat -f --freq=0.5 | textimg -o out.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.

#### 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.

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.

#### 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.

#### 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.

#### 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.

### 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 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
```

## 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
* <https://misc.flogisoft.com/bash/tip_colors_and_formatting>
================================================
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 <number> 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 <number> Action6 delimiter <number> Action7 delimiter <number> 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@.*<td>([0-9]+)</td>.*<td>(rgb[^<]+)</td>.*@\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
================================================
[38;5;0m000[38;5;1m001[38;5;2m002[38;5;3m003[38;5;4m004[38;5;5m005[38;5;6m006[38;5;7m007[38;5;8m008[38;5;9m009[38;5;10m010[38;5;11m011[38;5;12m012[38;5;13m013[38;5;14m014[38;5;15m015
[38;5;16m016[38;5;17m017[38;5;18m018[38;5;19m019[38;5;20m020[38;5;21m021[38;5;22m022[38;5;23m023[38;5;24m024[38;5;25m025[38;5;26m026[38;5;27m027[38;5;28m028[38;5;29m029[38;5;30m030[38;5;31m031
[38;5;32m032[38;5;33m033[38;5;34m034[38;5;35m035[38;5;36m036[38;5;37m037[38;5;38m038[38;5;39m039[38;5;40m040[38;5;41m041[38;5;42m042[38;5;43m043[38;5;44m044[38;5;45m045[38;5;46m046[38;5;47m047
[38;5;48m048[38;5;49m049[38;5;50m050[38;5;51m051[38;5;52m052[38;5;53m053[38;5;54m054[38;5;55m055[38;5;56m056[38;5;57m057[38;5;58m058[38;5;59m059[38;5;60m060[38;5;61m061[38;5;62m062[38;5;63m063
[38;5;64m064[38;5;65m065[38;5;66m066[38;5;67m067[38;5;68m068[38;5;69m069[38;5;70m070[38;5;71m071[38;5;72m072[38;5;73m073[38;5;74m074[38;5;75m075[38;5;76m076[38;5;77m077[38;5;78m078[38;5;79m079
[38;5;80m080[38;5;81m081[38;5;82m082[38;5;83m083[38;5;84m084[38;5;85m085[38;5;86m086[38;5;87m087[38;5;88m088[38;5;89m089[38;5;90m090[38;5;91m091[38;5;92m092[38;5;93m093[38;5;94m094[38;5;95m095
[38;5;96m096[38;5;97m097[38;5;98m098[38;5;99m099[38;5;100m100[38;5;101m101[38;5;102m102[38;5;103m103[38;5;104m104[38;5;105m105[38;5;106m106[38;5;107m107[38;5;108m108[38;5;109m109[38;5;110m110[38;5;111m111
[38;5;112m112[38;5;113m113[38;5;114m114[38;5;115m115[38;5;116m116[38;5;117m117[38;5;118m118[38;5;119m119[38;5;120m120[38;5;121m121[38;5;122m122[38;5;123m123[38;5;124m124[38;5;125m125[38;5;126m126[38;5;127m127
[38;5;128m128[38;5;129m129[38;5;130m130[38;5;131m131[38;5;132m132[38;5;133m133[38;5;134m134[38;5;135m135[38;5;136m136[38;5;137m137[38;5;138m138[38;5;139m139[38;5;140m140[38;5;141m141[38;5;142m142[38;5;143m143
[38;5;144m144[38;5;145m145[38;5;146m146[38;5;147m147[38;5;148m148[38;5;149m149[38;5;150m150[38;5;151m151[38;5;152m152[38;5;153m153[38;5;154m154[38;5;155m155[38;5;156m156[38;5;157m157[38;5;158m158[38;5;159m159
[38;5;160m160[38;5;161m161[38;5;162m162[38;5;163m163[38;5;164m164[38;5;165m165[38;5;166m166[38;5;167m167[38;5;168m168[38;5;169m169[38;5;170m170[38;5;171m171[38;5;172m172[38;5;173m173[38;5;174m174[38;5;175m175
[38;5;176m176[38;5;177m177[38;5;178m178[38;5;179m179[38;5;180m180[38;5;181m181[38;5;182m182[38;5;183m183[38;5;184m184[38;5;185m185[38;5;186m186[38;5;187m187[38;5;188m188[38;5;189m189[38;5;190m190[38;5;191m191
[38;5;192m192[38;5;193m193[38;5;194m194[38;5;195m195[38;5;196m196[38;5;197m197[38;5;198m198[38;5;199m199[38;5;200m200[38;5;201m201[38;5;202m202[38;5;203m203[38;5;204m204[38;5;205m205[38;5;206m206[38;5;207m207
[38;5;208m208[38;5;209m209[38;5;210m210[38;5;211m211[38;5;212m212[38;5;213m213[38;5;214m214[38;5;215m215[38;5;216m216[38;5;217m217[38;5;218m218[38;5;219m219[38;5;220m220[38;5;221m221[38;5;222m222[38;5;223m223
[38;5;224m224[38;5;225m225[38;5;226m226[38;5;227m227[38;5;228m228[38;5;229m229[38;5;230m230[38;5;231m231[38;5;232m232[38;5;233m233[38;5;234m234[38;5;235m235[38;5;236m236[38;5;237m237[38;5;238m238[38;5;239m239
[38;5;240m240[38;5;241m241[38;5;242m242[38;5;243m243[38;5;244m244[38;5;245m245[38;5;246m246[38;5;247m247[38;5;248m248[38;5;249m249[38;5;250m250[38;5;251m251[38;5;252m252[38;5;253m253[38;5;254m254[38;5;255m255
================================================
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
================================================
[38;2;0;0;0m000[38;2;1;0;0m001[38;2;2;0;0m002[38;2;3;0;0m003[38;2;4;0;0m004[38;2;5;0;0m005[38;2;6;0;0m006[38;2;7;0;0m007[38;2;8;0;0m008[38;2;9;0;0m009[38;2;10;0;0m010[38;2;11;0;0m011[38;2;12;0;0m012[38;2;13;0;0m013[38;2;14;0;0m014[38;2;15;0;0m015
[38;2;16;0;0m016[38;2;17;0;0m017[38;2;18;0;0m018[38;2;19;0;0m019[38;2;20;0;0m020[38;2;21;0;0m021[38;2;22;0;0m022[38;2;23;0;0m023[38;2;24;0;0m024[38;2;25;0;0m025[38;2;26;0;0m026[38;2;27;0;0m027[38;2;28;0;0m028[38;2;29;0;0m029[38;2;30;0;0m030[38;2;31;0;0m031
[38;2;32;0;0m032[38;2;33;0;0m033[38;2;34;0;0m034[38;2;35;0;0m035[38;2;36;0;0m036[38;2;37;0;0m037[38;2;38;0;0m038[38;2;39;0;0m039[38;2;40;0;0m040[38;2;41;0;0m041[38;2;42;0;0m042[38;2;43;0;0m043[38;2;44;0;0m044[38;2;45;0;0m045[38;2;46;0;0m046[38;2;47;0;0m047
[38;2;48;0;0m048[38;2;49;0;0m049[38;2;50;0;0m050[38;2;51;0;0m051[38;2;52;0;0m052[38;2;53;0;0m053[38;2;54;0;0m054[38;2;55;0;0m055[38;2;56;0;0m056[38;2;57;0;0m057[38;2;58;0;0m058[38;2;59;0;0m059[38;2;60;0;0m060[38;2;61;0;0m061[38;2;62;0;0m062[38;2;63;0;0m063
[38;2;64;0;0m064[38;2;65;0;0m065[38;2;66;0;0m066[38;2;67;0;0m067[38;2;68;0;0m068[38;2;69;0;0m069[38;2;70;0;0m070[38;2;71;0;0m071[38;2;72;0;0m072[38;2;73;0;0m073[38;2;74;0;0m074[38;2;75;0;0m075[38;2;76;0;0m076[38;2;77;0;0m077[38;2;78;0;0m078[38;2;79;0;0m079
[38;2;80;0;0m080[38;2;81;0;0m081[38;2;82;0;0m082[38;2;83;0;0m083[38;2;84;0;0m084[38;2;85;0;0m085[38;2;86;0;0m086[38;2;87;0;0m087[38;2;88;0;0m088[38;2;89;0;0m089[38;2;90;0;0m090[38;2;91;0;0m091[38;2;92;0;0m092[38;2;93;0;0m093[38;2;94;0;0m094[38;2;95;0;0m095
[38;2;96;0;0m096[38;2;97;0;0m097[38;2;98;0;0m098[38;2;99;0;0m099[38;2;100;0;0m100[38;2;101;0;0m101[38;2;102;0;0m102[38;2;103;0;0m103[38;2;104;0;0m104[38;2;105;0;0m105[38;2;106;0;0m106[38;2;107;0;0m107[38;2;108;0;0m108[38;2;109;0;0m109[38;2;110;0;0m110[38;2;111;0;0m111
[38;2;112;0;0m112[38;2;113;0;0m113[38;2;114;0;0m114[38;2;115;0;0m115[38;2;116;0;0m116[38;2;117;0;0m117[38;2;118;0;0m118[38;2;119;0;0m119[38;2;120;0;0m120[38;2;121;0;0m121[38;2;122;0;0m122[38;2;123;0;0m123[38;2;124;0;0m124[38;2;125;0;0m125[38;2;126;0;0m126[38;2;127;0;0m127
[38;2;128;0;0m128[38;2;129;0;0m129[38;2;130;0;0m130[38;2;131;0;0m131[38;2;132;0;0m132[38;2;133;0;0m133[38;2;134;0;0m134[38;2;135;0;0m135[38;2;136;0;0m136[38;2;137;0;0m137[38;2;138;0;0m138[38;2;139;0;0m139[38;2;140;0;0m140[38;2;141;0;0m141[38;2;142;0;0m142[38;2;143;0;0m143
[38;2;144;0;0m144[38;2;145;0;0m145[38;2;146;0;0m146[38;2;147;0;0m147[38;2;148;0;0m148[38;2;149;0;0m149[38;2;150;0;0m150[38;2;151;0;0m151[38;2;152;0;0m152[38;2;153;0;0m153[38;2;154;0;0m154[38;2;155;0;0m155[38;2;156;0;0m156[38;2;157;0;0m157[38;2;158;0;0m158[38;2;159;0;0m159
[38;2;160;0;0m160[38;2;161;0;0m161[38;2;162;0;0m162[38;2;163;0;0m163[38;2;164;0;0m164[38;2;165;0;0m165[38;2;166;0;0m166[38;2;167;0;0m167[38;2;168;0;0m168[38;2;169;0;0m169[38;2;170;0;0m170[38;2;171;0;0m171[38;2;172;0;0m172[38;2;173;0;0m173[38;2;174;0;0m174[38;2;175;0;0m175
[38;2;176;0;0m176[38;2;177;0;0m177[38;2;178;0;0m178[38;2;179;0;0m179[38;2;180;0;0m180[38;2;181;0;0m181[38;2;182;0;0m182[38;2;183;0;0m183[38;2;184;0;0m184[38;2;185;0;0m185[38;2;186;0;0m186[38;2;187;0;0m187[38;2;188;0;0m188[38;2;189;0;0m189[38;2;190;0;0m190[38;2;191;0;0m191
[38;2;192;0;0m192[38;2;193;0;0m193[38;2;194;0;0m194[38;2;195;0;0m195[38;2;196;0;0m196[38;2;197;0;0m197[38;2;198;0;0m198[38;2;199;0;0m199[38;2;200;0;0m200[38;2;201;0;0m201[38;2;202;0;0m202[38;2;203;0;0m203[38;2;204;0;0m204[38;2;205;0;0m205[38;2;206;0;0m206[38;2;207;0;0m207
[38;2;208;0;0m208[38;2;209;0;0m209[38;2;210;0;0m210[38;2;211;0;0m211[38;2;212;0;0m212[38;2;213;0;0m213[38;2;214;0;0m214[38;2;215;0;0m215[38;2;216;0;0m216[38;2;217;0;0m217[38;2;218;0;0m218[38;2;219;0;0m219[38;2;220;0;0m220[38;2;221;0;0m221[38;2;222;0;0m222[38;2;223;0;0m223
[38;2;224;0;0m224[38;2;225;0;0m225[38;2;226;0;0m226[38;2;227;0;0m227[38;2;228;0;0m228[38;2;229;0;0m229[38;2;230;0;0m230[38;2;231;0;0m231[38;2;232;0;0m232[38;2;233;0;0m233[38;2;234;0;0m234[38;2;235;0;0m235[38;2;236;0;0m236[38;2;237;0;0m237[38;2;238;0;0m238[38;2;239;0;0m239
[38;2;240;0;0m240[38;2;241;0;0m241[38;2;242;0;0m242[38;2;243;0;0m243[38;2;244;0;0m244[38;2;245;0;0m245[38;2;246;0;0m246[38;2;247;0;0m247[38;2;248;0;0m248[38;2;249;0;0m249[38;2;250;0;0m250[38;2;251;0;0m251[38;2;252;0;0m252[38;2;253;0;0m253[38;2;254;0;0m254[38;2;255;0;0m255
================================================
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)
})
}
}
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
SYMBOL INDEX (216 symbols across 26 files)
FILE: color/color.go
type RGBA (line 7) | type RGBA
FILE: config/config.go
type Config (line 19) | type Config struct
method Adjust (line 73) | func (a *Config) Adjust(args []string, ev EnvVars) error {
method SetFontFileAndFontIndex (line 155) | func (a *Config) SetFontFileAndFontIndex(runtimeOS string) {
method addTimeStampToOutPath (line 206) | func (a *Config) addTimeStampToOutPath(t time.Time) {
method addNumberSuffixToOutPath (line 218) | func (a *Config) addNumberSuffixToOutPath() {
method setWriter (line 242) | func (a *Config) setWriter() error {
type osDefaultFont (line 54) | type osDefaultFont struct
constant ShellgeiEmojiFontPath (line 60) | ShellgeiEmojiFontPath = "/usr/share/fonts/truetype/ancient-scripts/Symbo...
constant defaultWindowsFont (line 63) | defaultWindowsFont = `C:\Windows\Fonts\msgothic.ttc`
constant defaultDarwinFont (line 64) | defaultDarwinFont = "/System/Library/Fonts/AppleSDGothicNeo.ttc"
constant defaultIOSFont (line 65) | defaultIOSFont = "/System/Library/Fonts/Core/AppleSDGothicNeo.ttc"
constant defaultAndroidFont (line 66) | defaultAndroidFont = "/system/fonts/NotoSansCJK-Regular.ttc"
constant defaultLinuxFont1 (line 67) | defaultLinuxFont1 = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular...
constant defaultLinuxFont2 (line 68) | defaultLinuxFont2 = "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
function validateInputText (line 276) | func validateInputText(texts []string) error {
function validateFileExtension (line 291) | func validateFileExtension(ext string) error {
function normalizeTexts (line 303) | func normalizeTexts(texts []string) []string {
function readInputText (line 319) | func readInputText(args []string) []string {
function outputImageDir (line 332) | func outputImageDir(outDir string, useAnimation bool) (string, error) {
function optionColorStringToRGBA (line 353) | func optionColorStringToRGBA(colstr string) (color.RGBA, error) {
function toSlideStrings (line 405) | func toSlideStrings(src []string, lineCount, slideWidth int, slideForeve...
function removeZeroWidthCharacters (line 447) | func removeZeroWidthCharacters(s string) string {
function readStdin (line 468) | func readStdin() (ret []string) {
FILE: config/config_test.go
function newDefaultConfig (line 13) | func newDefaultConfig() Config {
function TestConfig_Adjust (line 42) | func TestConfig_Adjust(t *testing.T) {
function TestOptionColorStringToRGBA (line 327) | func TestOptionColorStringToRGBA(t *testing.T) {
function TestToSlideStrings (line 374) | func TestToSlideStrings(t *testing.T) {
function TestRemoveZeroWidthCharacters (line 587) | func TestRemoveZeroWidthCharacters(t *testing.T) {
function TestApplicationConfigSetFontFileAndFontIndex (line 607) | func TestApplicationConfigSetFontFileAndFontIndex(t *testing.T) {
function TestApplicationConfig_AddTimeStampToOutPath (line 673) | func TestApplicationConfig_AddTimeStampToOutPath(t *testing.T) {
function TestOutputImageDir (line 733) | func TestOutputImageDir(t *testing.T) {
function TestApplicationConfig_AddNumberSuffixToOutPath (line 785) | func TestApplicationConfig_AddNumberSuffixToOutPath(t *testing.T) {
FILE: config/envvar.go
type EnvVars (line 8) | type EnvVars struct
constant envNameOutputDir (line 16) | envNameOutputDir = "TEXTIMG_OUTPUT_DIR"
constant envNameFontFile (line 17) | envNameFontFile = "TEXTIMG_FONT_FILE"
constant envNameEmojiDir (line 18) | envNameEmojiDir = "TEXTIMG_EMOJI_DIR"
constant envNameEmojiFontFile (line 19) | envNameEmojiFontFile = "TEXTIMG_EMOJI_FONT_FILE"
function NewEnvVars (line 31) | func NewEnvVars() EnvVars {
function PrintEnvs (line 40) | func PrintEnvs() {
FILE: config/face.go
function readFace (line 15) | func readFace(fontPath string, fontIndex int, fontSize float64) (font.Fa...
FILE: config/face_test.go
function TestReadFace (line 10) | func TestReadFace(t *testing.T) {
FILE: config/writer_mock.go
type MockWriter (line 8) | type MockWriter struct
method Write (line 20) | func (m *MockWriter) Write(p []byte) (n int, err error) {
method Close (line 27) | func (m *MockWriter) Close() error {
function NewMockWriter (line 13) | func NewMockWriter(w, c bool) io.WriteCloser {
FILE: image/encode.go
method Encode (line 14) | func (i *Image) Encode(w io.Writer, ext string) error {
function toPalettes (line 37) | func toPalettes(imgs []image.Image) (ret []*image.Paletted) {
FILE: image/image.go
type Image (line 19) | type Image struct
method Draw (line 107) | func (i *Image) Draw(tokens token.Tokens) error {
method drawBackgroundAll (line 159) | func (i *Image) drawBackgroundAll() {
method updateColor (line 172) | func (i *Image) updateColor(t token.ColorType, col color.RGBA) {
method resetColor (line 189) | func (i *Image) resetColor() {
method resetPosition (line 194) | func (i *Image) resetPosition() {
method newDrawer (line 200) | func (i *Image) newDrawer(f font.Face) *font.Drawer {
method draw (line 217) | func (i *Image) draw(r rune) error {
method setAnimationFlames (line 229) | func (i *Image) setAnimationFlames() error {
method drawRune (line 260) | func (i *Image) drawRune(r rune, f font.Face) {
method drawEmoji (line 265) | func (i *Image) drawEmoji(r rune, path string) error {
method drawBackground (line 290) | func (i *Image) drawBackground(s string) {
method moveRight (line 305) | func (i *Image) moveRight(r rune) {
method moveDown (line 309) | func (i *Image) moveDown() {
method newScaledImage (line 315) | func (i *Image) newScaledImage() *image.RGBA {
method scale (line 332) | func (i *Image) scale() {
type ImageParam (line 43) | type ImageParam struct
function init (line 61) | func init() {
function NewImage (line 66) | func NewImage(p *ImageParam) *Image {
function newImage (line 103) | func newImage(w, h int) *image.RGBA {
function scale (line 325) | func scale(img image.Image, w, h int) *image.RGBA {
FILE: image/util.go
function isEmoji (line 31) | func isEmoji(r rune, emojiDir string) (bool, string) {
function isExceptionallyCodePoint (line 46) | func isExceptionallyCodePoint(r rune) bool {
function isLinefeed (line 56) | func isLinefeed(r rune) bool {
FILE: internal/global/env.go
constant EnvNameOutputDir (line 4) | EnvNameOutputDir = "TEXTIMG_OUTPUT_DIR"
constant EnvNameFontFile (line 5) | EnvNameFontFile = "TEXTIMG_FONT_FILE"
constant EnvNameEmojiDir (line 6) | EnvNameEmojiDir = "TEXTIMG_EMOJI_DIR"
constant EnvNameEmojiFontFile (line 7) | EnvNameEmojiFontFile = "TEXTIMG_EMOJI_FONT_FILE"
FILE: internal/global/version.go
constant AppName (line 4) | AppName = "textimg"
constant Version (line 5) | Version = `3.1.10
FILE: log/log.go
constant debugPrefix (line 13) | debugPrefix = "[DEBUG]"
constant infoPrefix (line 14) | infoPrefix = "[INFO]"
constant warnPrefix (line 15) | warnPrefix = "[WARN]"
constant errorPrefix (line 16) | errorPrefix = "[ERROR]"
function log (line 19) | func log(lvl string, msg interface{}) {
function Debug (line 31) | func Debug(msg interface{}) {
function Info (line 35) | func Info(msg interface{}) {
function Warn (line 39) | func Warn(msg interface{}) {
function Warnf (line 43) | func Warnf(format string, msg interface{}) {
function Error (line 48) | func Error(msg interface{}) {
FILE: log/log_test.go
function TestDebug (line 7) | func TestDebug(t *testing.T) {
FILE: main.go
function main (line 7) | func main() {
function Main (line 11) | func Main() int {
FILE: main_test.go
constant inDir (line 11) | inDir = "testdata/in"
constant outDir (line 12) | outDir = "testdata/out"
function TestMain (line 15) | func TestMain(m *testing.M) {
function testBefore (line 21) | func testBefore() {
FILE: parser/grammar.peg.go
constant endSymbol (line 14) | endSymbol rune = 1114112
type pegRule (line 17) | type pegRule
constant ruleUnknown (line 20) | ruleUnknown pegRule = iota
constant ruleroot (line 21) | ruleroot
constant ruleignore (line 22) | ruleignore
constant rulecolors (line 23) | rulecolors
constant ruletext (line 24) | ruletext
constant rulecolor (line 25) | rulecolor
constant rulestandard_color (line 26) | rulestandard_color
constant ruleextended_color (line 27) | ruleextended_color
constant ruleextended_color_256 (line 28) | ruleextended_color_256
constant ruleextended_color_rgb (line 29) | ruleextended_color_rgb
constant ruleextended_color_prefix (line 30) | ruleextended_color_prefix
constant ruletext_attributes (line 31) | ruletext_attributes
constant rulezero (line 32) | rulezero
constant rulenumber (line 33) | rulenumber
constant ruleprefix (line 34) | ruleprefix
constant ruleescape_sequence (line 35) | ruleescape_sequence
constant rulecolor_suffix (line 36) | rulecolor_suffix
constant rulenon_color_suffix (line 37) | rulenon_color_suffix
constant ruledelimiter (line 38) | ruledelimiter
constant ruleAction0 (line 39) | ruleAction0
constant rulePegText (line 40) | rulePegText
constant ruleAction1 (line 41) | ruleAction1
constant ruleAction2 (line 42) | ruleAction2
constant ruleAction3 (line 43) | ruleAction3
constant ruleAction4 (line 44) | ruleAction4
constant ruleAction5 (line 45) | ruleAction5
constant ruleAction6 (line 46) | ruleAction6
constant ruleAction7 (line 47) | ruleAction7
constant ruleAction8 (line 48) | ruleAction8
constant ruleAction9 (line 49) | ruleAction9
constant ruleAction10 (line 50) | ruleAction10
constant ruleAction11 (line 51) | ruleAction11
type token32 (line 89) | type token32 struct
method String (line 94) | func (t *token32) String() string {
type node32 (line 98) | type node32 struct
method print (line 103) | func (node *node32) print(w io.Writer, pretty bool, buffer string) {
method Print (line 126) | func (node *node32) Print(w io.Writer, buffer string) {
method PrettyPrint (line 130) | func (node *node32) PrettyPrint(w io.Writer, buffer string) {
type tokens32 (line 134) | type tokens32 struct
method Trim (line 138) | func (t *tokens32) Trim(length uint32) {
method Print (line 142) | func (t *tokens32) Print() {
method AST (line 148) | func (t *tokens32) AST() *node32 {
method PrintSyntaxTree (line 173) | func (t *tokens32) PrintSyntaxTree(buffer string) {
method WriteSyntaxTree (line 177) | func (t *tokens32) WriteSyntaxTree(w io.Writer, buffer string) {
method PrettyPrintSyntaxTree (line 181) | func (t *tokens32) PrettyPrintSyntaxTree(buffer string) {
method Add (line 185) | func (t *tokens32) Add(rule pegRule, begin, end, index uint32) {
method Tokens (line 194) | func (t *tokens32) Tokens() []token32 {
type Parser (line 198) | type Parser struct
method Parse (line 210) | func (p *Parser) Parse(rule ...int) error {
method Reset (line 214) | func (p *Parser) Reset() {
method PrintSyntaxTree (line 278) | func (p *Parser) PrintSyntaxTree() {
method WriteSyntaxTree (line 286) | func (p *Parser) WriteSyntaxTree(w io.Writer) {
method SprintSyntaxTree (line 290) | func (p *Parser) SprintSyntaxTree() string {
method Execute (line 296) | func (p *Parser) Execute() {
method Init (line 348) | func (p *Parser) Init(options ...func(*Parser) error) error {
type textPosition (line 218) | type textPosition struct
type textPositionMap (line 222) | type textPositionMap
function translatePositions (line 224) | func translatePositions(buffer []rune, positions []int) textPositionMap {
type parseError (line 249) | type parseError struct
method Error (line 254) | func (e *parseError) Error() string {
function Pretty (line 335) | func Pretty(pretty bool) func(*Parser) error {
function Size (line 342) | func Size(size int) func(*Parser) error {
FILE: parser/parser.go
type ParserFunc (line 10) | type ParserFunc struct
method pushResetColor (line 28) | func (p *ParserFunc) pushResetColor() {
method pushResetForegroundColor (line 32) | func (p *ParserFunc) pushResetForegroundColor() {
method pushResetBackgroundColor (line 36) | func (p *ParserFunc) pushResetBackgroundColor() {
method pushReverseColor (line 40) | func (p *ParserFunc) pushReverseColor() {
method pushText (line 44) | func (p *ParserFunc) pushText(text string) {
method pushStandardColorWithCategory (line 48) | func (p *ParserFunc) pushStandardColorWithCategory(text string) {
method pushExtendedColor (line 52) | func (p *ParserFunc) pushExtendedColor(text string) {
method setExtendedColor256 (line 56) | func (p *ParserFunc) setExtendedColor256(text string) {
method setExtendedColorR (line 61) | func (p *ParserFunc) setExtendedColorR(text string) {
method setExtendedColorG (line 66) | func (p *ParserFunc) setExtendedColorG(text string) {
method setExtendedColorB (line 71) | func (p *ParserFunc) setExtendedColorB(text string) {
function Parse (line 15) | func Parse(s string) (token.Tokens, error) {
FILE: parser/parser_test.go
function TestParse (line 11) | func TestParse(t *testing.T) {
FILE: root.go
function init (line 21) | func init() {
function RunRootCommand (line 79) | func RunRootCommand(c config.Config, args []string, envs config.EnvVars)...
function complementWidthHeight (line 138) | func complementWidthHeight(x, y, w, h int) (int, int) {
FILE: root_common_test.go
function newDefaultConfig (line 5) | func newDefaultConfig() config.Config {
FILE: root_on_docker_test.go
function TestRunRootCommandOnDocker (line 17) | func TestRunRootCommandOnDocker(t *testing.T) {
FILE: root_test.go
function TestRunRootCommand (line 13) | func TestRunRootCommand(t *testing.T) {
function TestComplementWidthHeight (line 526) | func TestComplementWidthHeight(t *testing.T) {
FILE: scripts/width/main.go
function main (line 22) | func main() {
FILE: token/token.go
type Kind (line 12) | type Kind
type ColorType (line 13) | type ColorType
type Token (line 14) | type Token struct
type Tokens (line 20) | type Tokens
method MaxStringWidth (line 114) | func (t *Tokens) MaxStringWidth() int {
method StringLines (line 135) | func (t *Tokens) StringLines() []string {
constant KindEmpty (line 24) | KindEmpty Kind = iota
constant KindText (line 25) | KindText
constant KindColor (line 26) | KindColor
constant KindNotColor (line 27) | KindNotColor
constant ColorTypeReset (line 29) | ColorTypeReset ColorType = iota
constant ColorTypeBold (line 30) | ColorTypeBold
constant ColorTypeDim (line 31) | ColorTypeDim
constant ColorTypeItalic (line 32) | ColorTypeItalic
constant ColorTypeUnderline (line 33) | ColorTypeUnderline
constant ColorTypeBlink (line 34) | ColorTypeBlink
constant ColorTypeSpeedyBlink (line 35) | ColorTypeSpeedyBlink
constant ColorTypeReverse (line 36) | ColorTypeReverse
constant ColorTypeHide (line 37) | ColorTypeHide
constant ColorTypeDelete (line 38) | ColorTypeDelete
constant ColorTypeForeground (line 39) | ColorTypeForeground
constant ColorTypeBackground (line 40) | ColorTypeBackground
constant ColorTypeResetForeground (line 41) | ColorTypeResetForeground
constant ColorTypeResetBackground (line 42) | ColorTypeResetBackground
function init (line 45) | func init() {
function NewResetColor (line 50) | func NewResetColor() Token {
function NewResetForegroundColor (line 57) | func NewResetForegroundColor() Token {
function NewResetBackgroundColor (line 64) | func NewResetBackgroundColor() Token {
function NewReverseColor (line 71) | func NewReverseColor() Token {
function NewText (line 78) | func NewText(text string) Token {
function NewStandardColorWithCategory (line 85) | func NewStandardColorWithCategory(text string) Token {
function NewExtendedColor (line 94) | func NewExtendedColor(text string) Token {
function colorType (line 103) | func colorType(n int) ColorType {
FILE: token/token_test.go
function TestToken_MaxStringWidth (line 10) | func TestToken_MaxStringWidth(t *testing.T) {
function TestToken_StringLines (line 93) | func TestToken_StringLines(t *testing.T) {
Condensed preview — 55 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (204K chars).
[
{
"path": ".chglog/CHANGELOG.tpl.md",
"chars": 501,
"preview": "{{ range .Versions }}\n## Changes\n\n{{ range .CommitGroups -}}\n### {{ .Title }}\n\n{{ range .Commits -}}\n* {{ .Subject }}\n{{"
},
{
"path": ".chglog/config.yml",
"chars": 525,
"preview": "style: github\ntemplate: CHANGELOG.tpl.md\ninfo:\n title: CHANGELOG\n repository_url: https://github.com/YOUR_NAME/REPOSIT"
},
{
"path": ".dockerignore",
"chars": 15,
"preview": "bin\nbuild\n.git\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 659,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: jiro4989\n\n---\n\n## Descri"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 605,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: feature\nassignees: jiro4989\n\n---\n\n##"
},
{
"path": ".github/dependabot.yml",
"chars": 830,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/pr-labeler.yml",
"chars": 48,
"preview": "feature: feature/*\nbug: hotfix/*\nchore: chore/*\n"
},
{
"path": ".github/workflows/auto_merge.yml",
"chars": 850,
"preview": "---\nname: Dependabot auto-merge\n\"on\": pull_request\n\npermissions:\n pull-requests: write\n contents: write\n\njobs:\n depen"
},
{
"path": ".github/workflows/pr-labeler.yml",
"chars": 333,
"preview": "name: labeler\n\non:\n pull_request:\n types: [opened]\n\njobs:\n labeler:\n runs-on: ubuntu-latest\n steps:\n - u"
},
{
"path": ".github/workflows/release.yml",
"chars": 7501,
"preview": "---\nname: release\n\n\"on\":\n push:\n tags:\n - 'v*.*.*'\n\nenv:\n app: textimg\n goversion: '1.25'\n build_opts: '-ldf"
},
{
"path": ".github/workflows/test.yml",
"chars": 3110,
"preview": "---\n\nname: test\n\n\"on\":\n push:\n branches:\n - master\n paths-ignore:\n - README*\n - LICENSE\n pull_req"
},
{
"path": ".gitignore",
"chars": 122,
"preview": "/bin/\n/dist/\n/testdata/out/\n/testdata/out*/\nimages/*\n!images/.gitkeep\nscripts/width/width\n\n!completions/*/textimg\ntextim"
},
{
"path": "Dockerfile",
"chars": 1471,
"preview": "FROM golang:1.26-alpine3.22 AS base\n\nRUN go version \\\n && echo $GOPATH \\\n && apk update \\\n && apk add --no-cach"
},
{
"path": "LICENSE",
"chars": 1065,
"preview": "MIT License\n\nCopyright (c) 2019 jiro4989\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
},
{
"path": "Makefile",
"chars": 924,
"preview": "textimg: parser/grammar.peg.go *.go */*.go\n\tgo fmt ./...\n\tgo build\n\nparser/grammar.peg.go: parser/grammar.peg\n\tpeg parse"
},
{
"path": "README.md",
"chars": 11270,
"preview": "# textimg\n\n\n[\n\ntype RGBA c.RGBA\n\nvar (\n\tRGBABlack = RGBA{0, 0, 0, 255}\n\tRGBARed "
},
{
"path": "completions/fish/textimg.fish",
"chars": 1668,
"preview": "complete -c textimg -x\n\ncomplete -c textimg -s g -l foreground -a 'black red green yellow blue magenta cyan white' -d 'f"
},
{
"path": "completions/zsh/_textimg",
"chars": 1652,
"preview": "#compdef textimg\n\n_textimg() {\n _arguments \\\n {-g,--foreground}'[foreground text color]: :->color' \\\n {-b,--backg"
},
{
"path": "config/config.go",
"chars": 10243,
"preview": "package config\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jiro49"
},
{
"path": "config/config_test.go",
"chars": 21242,
"preview": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github.com"
},
{
"path": "config/envvar.go",
"chars": 925,
"preview": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\ntype EnvVars struct {\n\tEmojiDir string\n\tOutputDir string\n\tFontFile "
},
{
"path": "config/face.go",
"chars": 1219,
"preview": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/jiro4989/textimg/v3/log\"\n\t\"golang.org/x/image/f"
},
{
"path": "config/face_test.go",
"chars": 1501,
"preview": "package config\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestReadFace(t *test"
},
{
"path": "config/writer_mock.go",
"chars": 447,
"preview": "package config\n\nimport (\n\t\"errors\"\n\t\"io\"\n)\n\ntype MockWriter struct {\n\twriteErr bool\n\tcloseErr bool\n}\n\nfunc NewMockWriter"
},
{
"path": "docker-compose.yml",
"chars": 686,
"preview": "---\n\nversion: '3.7'\n\nservices:\n base: &common\n build:\n context: ./\n dockerfile: ./Dockerfile\n target:"
},
{
"path": "go.mod",
"chars": 750,
"preview": "module github.com/jiro4989/textimg/v3\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/k"
},
{
"path": "go.sum",
"chars": 3326,
"preview": "github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=\ngithub.com/clipperhouse/uax29/v2"
},
{
"path": "image/encode.go",
"chars": 907,
"preview": "package image\n\nimport (\n\t\"fmt\"\n\t\"image\"\n\t\"image/color/palette\"\n\t\"image/draw\"\n\t\"image/gif\"\n\t\"image/jpeg\"\n\t\"image/png\"\n\t\"i"
},
{
"path": "image/image.go",
"chars": 8002,
"preview": "package image\n\nimport (\n\t\"image\"\n\tc \"image/color\"\n\t\"image/draw\"\n\t\"os\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github."
},
{
"path": "image/util.go",
"chars": 989,
"preview": "package image\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nvar (\n\t// 絵文字描画の際に、普通に描画してほしいけれど絵文字としても定義されている\n\t// 文字のコードポイント\n\texRunes = []rune{"
},
{
"path": "images/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "internal/global/env.go",
"chars": 325,
"preview": "package global\n\nconst (\n\tEnvNameOutputDir = \"TEXTIMG_OUTPUT_DIR\"\n\tEnvNameFontFile = \"TEXTIMG_FONT_FILE\"\n\tEnvNam"
},
{
"path": "internal/global/version.go",
"chars": 163,
"preview": "package global\n\nconst (\n\tAppName = \"textimg\"\n\tVersion = `3.1.10\nCopyright (c) 2019 jiro4989\nReleased under the MIT Licen"
},
{
"path": "log/log.go",
"chars": 855,
"preview": "package log\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/jiro4989/textimg/v3/internal/global\"\n)\n\nconst (\n\tdeb"
},
{
"path": "log/log_test.go",
"chars": 147,
"preview": "package log\n\nimport (\n\t\"testing\"\n)\n\nfunc TestDebug(t *testing.T) {\n\tDebug(\"debug\")\n\tInfo(\"debug\")\n\tWarn(\"debug\")\n\tWarnf("
},
{
"path": "main.go",
"chars": 158,
"preview": "package main\n\nimport (\n\t\"os\"\n)\n\nfunc main() {\n\tos.Exit(Main())\n}\n\nfunc Main() int {\n\tif err := RootCommand.Execute(); er"
},
{
"path": "main_test.go",
"chars": 336,
"preview": "//go:build !docker\n\npackage main\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nconst (\n\tinDir = \"testdata/in\"\n\toutDir = \"testdata/out\"\n"
},
{
"path": "parser/grammar.peg",
"chars": 1390,
"preview": "package parser\n\ntype Parser Peg {\n ParserFunc\n}\n\nroot <-\n (colors / ignore / text)*\n\nignore <-\n prefix number? non_co"
},
{
"path": "parser/grammar.peg.go",
"chars": 27426,
"preview": "package parser\n\n// Code generated by peg parser/grammar.peg DO NOT EDIT.\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"sort\"\n\t\"strconv\""
},
{
"path": "parser/parser.go",
"chars": 1688,
"preview": "package parser\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github.com/jiro4989/textimg/v3/token\"\n)\n\n"
},
{
"path": "parser/parser_test.go",
"chars": 6362,
"preview": "package parser\n\nimport (\n\t\"testing\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github.com/jiro4989/textimg/v3/token\"\n\t\"g"
},
{
"path": "root.go",
"chars": 5429,
"preview": "package main\n\nimport (\n\t\"image/color\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/jiro4989/textimg/v3/config\"\n\t\"github.com/jiro4"
},
{
"path": "root_common_test.go",
"chars": 964,
"preview": "package main\n\nimport \"github.com/jiro4989/textimg/v3/config\"\n\nfunc newDefaultConfig() config.Config {\n\treturn config.Con"
},
{
"path": "root_on_docker_test.go",
"chars": 2524,
"preview": "//go:build docker\n\n//\n// 日本語や絵文字が使えるDocker環境上で実行する想定のテスト。\n// どうしてもDocker上でしかテストできないもののみこのファイルに記述する。\n\npackage main\n\nimpor"
},
{
"path": "root_test.go",
"chars": 15028,
"preview": "//go:build !docker\n\npackage main\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/jiro4989/textimg/v3/config\"\n\t\"github.com/stret"
},
{
"path": "scripts/fetch_color.sh",
"chars": 199,
"preview": "#!/bin/bash\n\ncurl \"https://jonasjacek.github.io/colors/\" \\\n | grep style \\\n | sed -r 's@.*<td>([0-9]+)</td>.*<td>(rgb["
},
{
"path": "scripts/width/main.go",
"chars": 427,
"preview": "// width は文字のrunewidthが返す文字幅を確認するためのツール。\n\n/*\n\n使い方\n\ncd tools/width\ngo build .\n./width あいうえお■漢字abcde😲\n\n*/\n\npackage main\n\ni"
},
{
"path": "testdata/in/255.txt",
"chars": 3490,
"preview": "\u001b[38;5;0m000\u001b[38;5;1m001\u001b[38;5;2m002\u001b[38;5;3m003\u001b[38;5;4m004\u001b[38;5;5m005\u001b[38;5;6m006\u001b[38;5;7m007\u001b[38;5;8m008\u001b[38;5;9m009"
},
{
"path": "testdata/in/illegal_font.otc",
"chars": 2,
"preview": "2\n"
},
{
"path": "testdata/in/illegal_font.ttc",
"chars": 2,
"preview": "1\n"
},
{
"path": "testdata/in/illegal_font.txt",
"chars": 2,
"preview": "3\n"
},
{
"path": "testdata/in/red_grad.txt",
"chars": 4514,
"preview": "\u001b[38;2;0;0;0m000\u001b[38;2;1;0;0m001\u001b[38;2;2;0;0m002\u001b[38;2;3;0;0m003\u001b[38;2;4;0;0m004\u001b[38;2;5;0;0m005\u001b[38;2;6;0;0m006\u001b[38;2;7"
},
{
"path": "token/token.go",
"chars": 2803,
"preview": "package token\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github.com/mattn/go-runewidth\"\n"
},
{
"path": "token/token_test.go",
"chars": 2286,
"preview": "package token\n\nimport (\n\t\"testing\"\n\n\t\"github.com/jiro4989/textimg/v3/color\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfun"
}
]
About this extraction
This page contains the full source code of the jiro4989/textimg GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 55 files (164.8 KB), approximately 62.5k tokens, and a symbol index with 216 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.