first version

This commit is contained in:
2020-10-05 20:26:29 +08:00
parent fdcd2f5042
commit ff12497673
17 changed files with 1811 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
/dist
+134
View File
@@ -0,0 +1,134 @@
# TextFrame
一个通过canvas将长文本分成若干页渲染的js工具,处理排版禁则,支持辅助平面Unicode字符。目前仅工作于浏览器环境。
## 项目介绍
在浏览器中,尽管css有分栏布局,但却很难实现“分页”。浏览器没有提供计算一个盒子可以显示多少文本的API,只有Canvas的上下文提供了一个`measureText()`方法,但它只能简单的计算一段文本的渲染宽度,当文本不止一行时就无法计算了。由于不同字体不同字符有不同的宽度,再加上各浏览器对排版禁则的处理不一致,基本杜绝了计算浏览器原生DOM元素能够显示多少文本的可能。
于是我参考W3C对中文排版的[草案](https://www.w3.org/TR/2020/WD-clreq-20201001/#prohibition_rules_for_line_start_end)中的排版禁则,通过[CanvasRenderingContext2D.measureText()](https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/measureText)方法计算字符宽度,对文本进行分页。
### 行首禁则
| 名称 | 符号 |
| ---- | ------ |
| 点号 | 、,,..。:;!? |
| 结束引号 | '"」』”’ |
| 结束括号 | )]})】〗〕]} |
| 结束乙式书名号 | 》〉 |
| 连接号 | –~~— |
| 间隔号 | ·.‧•・ |
| 分隔号 | // |
### 行尾禁则
| 名称 | 符号 |
| ---- | ------ |
| 开始引号 | 「『“‘ |
| 开始括号 | ([{(【〖〔[〔 |
| 开始单双书名号 | 《〈 |
| 分隔号 | // |
### 符号分离禁则
| 名称 | 符号 |
| ---- | ------ |
| 破折号 | ── |
| 省略号 | …… |
本项目默认按照上述禁则处理排版禁则,但可以通过[选项](https://mattuylee.github.io/text-frame/zh/options.md)控制排版禁则。
草案中规定,当碰到行首为(行首禁则中的)标点时,应遵守「先挤进,后推出」原则,即先尝试压缩当前行的标点,无法挤压再取前一行的最后一个字至下一行。考虑到标点符号压缩的复杂性,本项目暂未实现该特性,而是直接尝试取上一行的最后一字到下一行,如果直到上一行首都没找到允许出现在行首的字符,则采取不处理的方式。
## 功能
本项目定义了文本片段(text fragment)的概念。通过在选项中指定一个文本片段数组,可以对每一个片段设置不同的样式,如字体、颜色、对齐方式等,具体请参考[配置项](https://mattuylee.github.io/text-frame/zh/options.md)。一般来说,一个文本片段即一个文本段落,虽然文本片段内也支持换行,但不会有段间距和行缩进。在本项目文档中文本片段和文本段落等价,都是指`FragmentOptions`定义的文本片段。
本项目支持以下特性:
* 每个段落单独设置字体、缩进、边距、字体颜色等;
* 支持两端对齐;
* 支持自定义排版禁则;
* 可配置段落之间边距是否折叠;
* 支持rtl模式;
这是一个[在线例子](https://mattuylee.github.io/text-frame/zh/example.html)。
## 使用
本项目仅支持浏览器环境,且依赖于Canvas 2D上下文,请参考[canvas兼容性](https://caniuse.com/?search=canvas)。
### API
```typescript
// 计算分页
function computeTextFrames(options: FrameOptions): TextFrame[];
// 渲染分页到canvas
function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame, clear: boolean): void;
```
### npm
`npm install @mattuy/text-frame --save`
### ES2015 & CommonJS
```javascript
// cjs
// const { computeTextFrames, renderFrame } = require('@mattuy/text-frame');
// or esm
import { computeTextFrames, renderFrame } from '@mattuy/text-frame/esm';
const frames = computeTextFrames({
viewWidth: 320,
viewHeight: 640,
fontSize: 16,
margin: 8,
color: '#000',
fragments: [
{
color: 'green',
fontSize: 20,
margin: 32,
textAlign: 'center',
text: "标题"
},
{
textIndent: 32,
fontFamily: 'serif',
margin: { bottom: 32 },
marginCollapse: true,
textAlign: 'justify',
text: "这是一个多行文本。"
}
]
});
const canvas = document.createElement('canvas');
canvas.style.width = '320px';
canvas.style.height = '640px';
document.body.append(canvas);
renderFrame(canvas.getContext('2d'), frames[0]);
```
### 全局引入
```html
<script src="text-frame/dist/umd/text-frame-min.js"></script>
<script>
const frames = TextFrame.computeTextFrames({
// ...options
});
console.log(frames)
// ...render
</script>
```
配置说明请参考[配置项](https://mattuylee.github.io/text-frame/zh/options.md)。
## 源码编译
* 克隆仓库
`git clone https://github.com/mattuylee/text-frame.git`
* 安装依赖
`cd text-frame`
`npm install`
### 发布
`npm run dist`
### 启动demo
`npm run example`
### 调试代码
执行`npm run debug`,然后rollup会监听src下文件的变化自动重新编译。可浏览器直接打开`example/index.html`测试。
## Licence
MIT
+124
View File
@@ -0,0 +1,124 @@
# TextFrame
[中文版](https://github.com/mattuylee/text-frame) | [English](https://github.com/mattuylee/text-frame/docs/en/README-EN.md)
A javascript tool to split long text into frames, with typesetting prohibition processed and unicode full support.
## Introduction
It's difficute to compute how many characters a DOM element can show, because diffrent User Agents have diffrent typesetting rules. So for that, we have to draw text on a canvas with our own rules to know how to split text into several text frames, since we can compute character width through [CanvasRenderingContext2D.measureText()](https://developer.mozilla.org/en/docs/Web/API/CanvasRenderingContext2D/measureText).
refering to [W3C draft](https://www.w3.org/TR/2020/WD-clreq-20201001/#prohibition_rules_for_line_start_end) for Chinese typesetting, following are my rules:
### Prohibition Rules for Line Start
| Punctuation Name | The Punctuation Marks |
| ---- | ------ |
| Pause or Stop | 、,..。:;!? |
| Closing Quotation | '"」』”’ |
| Closing Parentheses | )]})】〗〕]} |
| Closing Angle Brackets | 》〉 |
| Connectors | ~~— |
| Interpuncts | ·.‧•・ |
| Solidi | / |
### Prohibition Rules for Line End
| Punctuation Name | The Punctuation Marks |
| ---- | ------ |
| Opening Quotation | 「『“‘ |
| opening Parentheses | ([{(【〖〔[〔 |
| Opening Angle Brackets | 《〈 |
| Solidi | / |
### Prohibition Rules for Unbreakable Marks
| Name | Mark |
| ---- | ------ |
| Em dash and long dash | ── |
| Ellipsis | …… |
This library take the above as default rules, but it can be configued with [options](https://mattuylee.github.io/text-frame/en/options.md).
Here is an online [demo](https://mattuylee.github.io/text-frame/en/example.html).
## Usage
Browser environment is required, and [canvas support](https://caniuse.com/?search=canvas) is needed. This library CANNOT work under web worker.
### API
```typescript
// compute text frames
function computeTextFrames(options: FrameOptions): TextFrame[];
// render text frame
function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame, clear: boolean): void;
```
### npm
`npm install @mattuy/text-frame --save`
### ES2015 & CommonJS
```javascript
// cjs
// const { computeTextFrames, renderFrame } = require('@mattuy/text-frame');
// or esm
import { computeTextFrames, renderFrame } from '@mattuy/text-frame/esm';
const frames = computeTextFrames({
viewWidth: 320,
viewHeight: 640,
fontSize: 16,
margin: 8,
color: '#000',
fragments: [
{
color: 'green',
fontSize: 20,
margin: 32,
textAlign: 'center',
text: "Caption"
},
{
textIndent: 32,
fontFamily: 'serif',
margin: { bottom: 32 },
marginCollapse: true,
textAlign: 'justify',
text: "This is a multi-line text. Pass your text paragraph as this."
}
]
});
const canvas = document.createElement('canvas');
canvas.style.width = '320px';
canvas.style.height = '640px';
document.body.append(canvas);
renderFrame(canvas.getContext('2d'), frames[0], true);
```
### Globally Script
```html
<script src="text-frame/dist/umd/text-frame-min.js"></script>
<script>
const frames = TextFrame.computeTextFrames({
// ...options
});
console.log(frames)
// ...render
</script>
```
Option reference sits [here](https://mattuylee.github.io/text-frame/en/options.md).
## Build from Source
* clone the repo
`git clone https://github.com/mattuylee/text-frame.git`
* install dependences
`cd text-frame`
`npm install`
### Distribution
`npm run dist`
### Start a demo serve
`npm run example`
### Debug
Run `npm run debug`, then open `example/index.html` in your browser, rollup will watch your code changes and automatically rebuild.
## Licence
MIT
+130
View File
@@ -0,0 +1,130 @@
# Options
## API Signature
```typescript
export function computeTextFrames(options: FrameOptions): TextFrame[];
export function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame, clear: boolean): void;
```
## FrameOptions
### viewWidth
* Type: `number`
* Default: `300`
* Description: view width, must equal to canvas.style.width, but be numeric
### viewHeight
* Type: `number`
* Default: `150`
* Description: view height
### canvasWidth
* Type: `number`
* Default: compute with `viewWidth`
* Description: canvas.widthdefault is `viewWidth * window.devicePixelRatio`
### canvasHeight
* Type: `number`
* Default: 根据`viewHeight`计算。
* Description: canvas.height is `viewHeight * window.devicePixelRatio`
### margin
* Type: `number | { left?: number, right?: number, top?: number, bottom?: number }`
* Default: `0`
* Description: margin for **frame**
### lineStartProhibitedMarks
* Type: `string`
* Default: `、,..。:;!?'"」』”’)]})】〗〕]}》〉–~~—·.‧•・//`
* Description: prohibition characters for line start
### lineEndProhibitedMarks
* Type: `string`
* Default: `「『“‘([{(【〖〔[〔《〈/`
* Description: prohibition characters for line end
### unbreakableRule
* Type: `RegExp`
* Default: `/──|……|[\w\d]+/`
* Description: unbreakable marks. If not empty, must be a RegExp instance. Marks match the rule will not be split into diffrent lines
### fragments
* Type: `FragmentOptions[]`
* Default: `null`
* Description: text fragments, typically one paragraph one fragment
## FrameOptions & FragmentOptions
Options for both frame and fragment. Fragment option will inhirit from frame option if it's empty except `margin`.
### fontFamily
* Type: `string`
* Default: `serif`
* Description: font family
### fontSize
* Type: `number`
* Default: `16`
* Description: font size
### fontWeight
* Type: `string | number`
* Default: `16`
* Description: font weight, refer CSS `font-weight`
### color
* Type: `string`
* Default: `#000000`
* Description: font color
### lineHeight
* Type: `number`
* Default: 1.5 * `fontSize`
* Description: line height
### textIndent
* Type: `number`
* Default: `0`
* Description: text indentation for first line of fragment. interal new line of a fragment is not processed
### textAlign
* Type: `'center' | 'start' | 'end' | 'left' | 'right' | 'justify'`
* Default: `start`
* Description: text alignment, refer CSS `text-align`
### textAlignLast
* Type: `'center' | 'start' | 'end' | 'left' | 'right' | 'justify'`
* Default: `start`
* Description: when `textAlign` is `justify`, how the last line is aligned. internal new line of a fragment is also processed. refer CSS `text-align-last`
### rtl
* Type: `boolean`
* Default: `false`
* Description: if true, draw text from right to left
### trim
* Type: `boolean`
* Default: `false`
* Description: if true, trim white characters of text of a fragment
### marginCollapse
* Type: `boolean`
* Default: `true`
* Description: if true, the margin-top a fragment is the max of its own and the previous one. if `marginCollapse` of the previous fragment is `false`, margin of current fragment will not collapse
### noHeadMargin
* Type: `boolean`
* Default: `false`
* Description: ignore margin-top if a fragment is on the top of a frame
## FragmentOptions
### margin
* Type: `number | { left?: number, right?: number, top?: number, bottom?: number }`
* Default: `0`
* Description: margin of a **fragment**
### text
* Type: `string`
* Default: `''`
* Description: text content of a fragment
View File
+129
View File
@@ -0,0 +1,129 @@
# 配置项
## 导出函数原型
```typescript
export function computeTextFrames(options: FrameOptions): TextFrame[];
export function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame): void;
```
## FrameOptions
### viewWidth
* 类型: `number`
* 默认值: `300`
* 说明: 指定视图宽度,用于计算`canvasWidth`。除非显式提供`canvasWidth`参数,否则必须提供此参数。`viewWidth`应与要实际绘制文本的canvas的css宽度相等。
### viewHeight
* 类型: `number`
* 默认值: `150`
* 说明:指定视图高度。
### canvasWidth
* 类型: `number`
* 默认值: 根据`viewWidth`计算。
* 说明: canvas画布宽度,对应canvas.width,默认为`viewWidth * window.devicePixelRatio`
### canvasHeight
* 类型: `number`
* 默认值: 根据`viewHeight`计算。
* 说明: canvas画布高度,对应canvas.height。
### margin
* 类型: `number | { left?: number, right?: number, top?: number, bottom?: number }`
* 默认值: `0`
* 说明: 每个分页(frame)的边距。注意,虽然`FragmentOptions`也有margin配置项,但二者并不是继承关系,而是作用于不同的对象:页面(frame)和文本段落(fragment)。
### lineStartProhibitedMarks
* 类型: `string`
* 默认值: `、,..。:;!?'"」』”’)]})】〗〕]}》〉–~~—·.‧•・//`
* 说明: 禁止出现在行首的字符。
### lineEndProhibitedMarks
* 类型: `string`
* 默认值: `「『“‘([{(【〖〔[〔《〈/`
* 说明: 禁止出现在行尾的字符。
### unbreakableRule
* 类型: `RegExp`
* 默认值: `/──|……|[\w\d]+/`
* 说明: 符号分离禁则规则。如果提供,必须为正则表达式,正则表达式匹配则该认为该组合不能被拆分到新行,默认为破折号,省略号,和英文字母、数字组合。
### fragments
* 类型: `FragmentOptions[]`
* 默认值: `null`
* 说明: 文本段落数组。必须提供此参数。
## FrameOptions & FragmentOptions
以下配置项既可以在`FrameOptions`中指定,也可以在`FragmentOptions`中指定。当`FragmentOptions`未指定相关参数时,将继承`FrameOptions`的配置(再次强调,`margin`并不会继承)。
### fontFamily
* 类型: `string`
* 默认值: `serif`
* 说明: 字体名称。
### fontSize
* 类型: `number`
* 默认值: `16`
* 说明: 字体大小。
### fontWeight
* 类型: `string | number`
* 默认值: `16`
* 说明: 字体粗细。参考CSS `font-weight`
### color
* 类型: `string`
* 默认值: `#000000`
* 说明: 字体颜色。
### lineHeight
* 类型: `number`
* 默认值: 1.5倍`fontSize`
* 说明: 行高。
### textIndent
* 类型: `number`
* 默认值: `0`
* 说明: 首行缩进。注意,对于每一个文本段落,仅首行缩进,即使文本段落中有换行符,新行也不会缩进。参考word中的软回车。若要分段落应提供多个文本段落。
### textAlign
* 类型: `'center' | 'start' | 'end' | 'left' | 'right' | 'justify'`
* 默认值: `start`
* 说明: 文本对齐方式。参考CSS `text-align`
### textAlignLast
* 类型: `'center' | 'start' | 'end' | 'left' | 'right' | 'justify'`
* 默认值: `start`
* 说明: 当`textAlign``justify`时(两端对齐),段落最后一行的对齐方式。注意,这里的最后一行包括文本片段内部的换行符前的最后一行。参考CSS `text-align-last`
### rtl
* 类型: `boolean`
* 默认值: `false`
* 说明: 是否从右到左渲染。
### trim
* 类型: `boolean`
* 默认值: `false`
* 说明: 是否自动删除文本片段首尾的空白符。注意,文本片段内部换行后行首尾空白符不会被清除。
### marginCollapse
* 类型: `boolean`
* 默认值: `true`
* 说明: 文本片段的上边距是否与前一文本片段的下边距折叠。注意,如果相邻的任一文本片段`marginCollapse`为false,则不会发生边距折叠。
### noHeadMargin
* 类型: `boolean`
* 默认值: `false`
* 说明: 当一个文本片段正好开始于一个空的页面时,是否忽略其上边距。
## FragmentOptions
### margin
* 类型: `number | { left?: number, right?: number, top?: number, bottom?: number }`
* 默认值: `0`
* 说明: 文本片段的边距。注意,此配置项不继承于`FrameOptions``margin`选项。
### text
* 类型: `string`
* 默认值: `''`
* 说明: 该文本段落要绘制的文本内容。
Vendored
+8
View File
@@ -0,0 +1,8 @@
import { FrameOptions, TextFrame } from "./src/options";
export function computeTextFrames(options: FrameOptions): TextFrame[];
export function renderFrame(
context: CanvasRenderingContext2D,
frame: TextFrame,
clear?: boolean
): void;
+6
View File
@@ -0,0 +1,6 @@
import { computeTextFrames, renderFrame } from './dist/esm/text-frame-min';
export {
computeTextFrames,
renderFrame
}
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
<style>
.example-container {
display: flex;
min-width: 360px;
flex-wrap: wrap;
justify-content: center;
max-width: 1024px;
margin: auto;
}
.example-item {
box-sizing: border-box;
min-width: 300px;
width: 300px;
margin: 16px;
}
.example-item:first-child {
flex-grow: 1;
}
#source {
width: 100%;
box-sizing: border-box;
font-size: 16px;
height: 360px;
padding: 1em;
}
#canvas {
width: 100%;
height: 360px;
border: 1px solid darkgray;
}
</style>
<script src="../dist/umd/text-frame.js"></script>
</head>
<body>
<div class="example-container">
<div class="example-item">
<textarea id="source" oninput="refresh()" spellcheck="false">
options = {
viewWidth: 300,
viewHeight: 360,
fontSize: 16,
margin: 16,
fragments: [
{
margin: { left: 16, right: 16, top: 32 },
marginCollapse: false,
text: "在这里输入文本。"
},
]
}
</textarea>
<button>Options Refrence</button>
</div>
<div class="example-item">
<canvas id="canvas"></canvas>
<button onclick="setPagination(-1)">Previous</button>
<button onclick="setPagination(1)">Next</button>
<span id="pagination" style="float: right;">0 / 0</span>
</div>
</div>
<script>
var options
, pagination = document.getElementById('pagination')
, canvas = document.getElementById('canvas')
, ctx = canvas.getContext('2d')
, pageIndex = 0
, frames = []
, defaultOptions = {
viewWidth: 300,
viewHeight: 360,
fontWeight: '700',
fontSize: 20,
color: 'red',
fragments: [
{
textAlign: 'center',
margin: 16,
marginCollapse: false,
text: "bad input"
},
]
}
canvas.width = 300 * devicePixelRatio;
canvas.height = 360 * devicePixelRatio;
window.onresize = refresh;
refresh();
function refresh() {
try {
var src = document.getElementById('source').value;
new Function(src)();
frames = TextFrame.computeTextFrames(options);
}
catch (e) {
console.error(e);
frames = TextFrame.computeTextFrames(defaultOptions);
}
pageIndex = 0;
setPagination(0)
}
function setPagination(delta) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!frames.length) {
pagination.textContent = '0 / 0';
return;
}
pageIndex += delta;
if (pageIndex < 0) { pageIndex = 0; }
else if (pageIndex >= frames.length) {
pageIndex = frames.length - 1;
}
TextFrame.renderFrame(ctx, frames[pageIndex]);
pagination.textContent = pageIndex + 1 + ' / ' + frames.length;
}
</script>
</body>
</html>
Vendored
+16
View File
@@ -0,0 +1,16 @@
import { FrameOptions, FragmentOptions, TextFrame } from "./src/options";
export as namespace TextFrame;
export function computeTextFrames(options: FrameOptions): TextFrame[];
export function renderFrame(
context: CanvasRenderingContext2D,
frame: TextFrame,
clear?: boolean
): void;
export {
FrameOptions,
FragmentOptions,
TextFrame
}
+12
View File
@@ -0,0 +1,12 @@
const devMode = process.env.NODE_ENV === 'development';
const TextFrame = require('./dist/umd/text-frame-min')
, TextFrameDev = require('./dist/umd/text-frame');
let _exports;
if (devMode) {
_exports = TextFrameDev;
}
else {
_exports = TextFrame;
}
module.exports = _exports;
+484
View File
@@ -0,0 +1,484 @@
{
"name": "text-frame",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npm.taobao.org/@babel/code-frame/download/@babel/code-frame-7.10.4.tgz",
"integrity": "sha1-Fo2ho26Q2miujUnA8bSMfGJJITo=",
"dev": true,
"requires": {
"@babel/highlight": "^7.10.4"
}
},
"@babel/helper-module-imports": {
"version": "7.10.4",
"resolved": "https://registry.npm.taobao.org/@babel/helper-module-imports/download/@babel/helper-module-imports-7.10.4.tgz?cache=0&sync_timestamp=1593522826853&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-module-imports%2Fdownload%2F%40babel%2Fhelper-module-imports-7.10.4.tgz",
"integrity": "sha1-TFxUvgS9MWcKc4J5fXW5+i5bViA=",
"dev": true,
"requires": {
"@babel/types": "^7.10.4"
}
},
"@babel/helper-validator-identifier": {
"version": "7.10.4",
"resolved": "https://registry.npm.taobao.org/@babel/helper-validator-identifier/download/@babel/helper-validator-identifier-7.10.4.tgz",
"integrity": "sha1-p4x6clHgH2FlEtMbEK3PUq2l4NI=",
"dev": true
},
"@babel/highlight": {
"version": "7.10.4",
"resolved": "https://registry.npm.taobao.org/@babel/highlight/download/@babel/highlight-7.10.4.tgz?cache=0&sync_timestamp=1593521095576&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhighlight%2Fdownload%2F%40babel%2Fhighlight-7.10.4.tgz",
"integrity": "sha1-fRvf1ldTU4+r5sOFls23bZrGAUM=",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@babel/types": {
"version": "7.11.5",
"resolved": "https://registry.npm.taobao.org/@babel/types/download/@babel/types-7.11.5.tgz?cache=0&sync_timestamp=1598904189191&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Ftypes%2Fdownload%2F%40babel%2Ftypes-7.11.5.tgz",
"integrity": "sha1-2d5XfQElLXfGgAzuA57mT691Zi0=",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"lodash": "^4.17.19",
"to-fast-properties": "^2.0.0"
}
},
"@rollup/plugin-babel": {
"version": "5.2.1",
"resolved": "https://registry.npm.taobao.org/@rollup/plugin-babel/download/@rollup/plugin-babel-5.2.1.tgz",
"integrity": "sha1-IPyPiGTcDqocVXhAhFlgaAj3KSQ=",
"dev": true,
"requires": {
"@babel/helper-module-imports": "^7.10.4",
"@rollup/pluginutils": "^3.1.0"
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npm.taobao.org/@rollup/pluginutils/download/@rollup/pluginutils-3.1.0.tgz",
"integrity": "sha1-cGtFJO5tyLEDs8mVUz5a1oDAK5s=",
"dev": true,
"requires": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
}
},
"@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npm.taobao.org/@types/estree/download/@types/estree-0.0.39.tgz",
"integrity": "sha1-4Xfmme4bjCLSMXTKqnQiZEOJUJ8=",
"dev": true
},
"@types/node": {
"version": "14.11.1",
"resolved": "https://registry.npm.taobao.org/@types/node/download/@types/node-14.11.1.tgz?cache=0&sync_timestamp=1600368053055&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-14.11.1.tgz",
"integrity": "sha1-Vq+QKtFX52P5umPWccOc2jGTyDU=",
"dev": true
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-3.2.1.tgz",
"integrity": "sha1-QfuyAkPlCxK+DwS43tvwdSDOhB0=",
"dev": true,
"requires": {
"color-convert": "^1.9.0"
}
},
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npm.taobao.org/buffer-from/download/buffer-from-1.1.1.tgz",
"integrity": "sha1-MnE7wCj3XAL9txDXx7zsHyxgcO8=",
"dev": true
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npm.taobao.org/chalk/download/chalk-2.4.2.tgz",
"integrity": "sha1-zUJUFnelQzPPVBpJEIwUMrRMlCQ=",
"dev": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npm.taobao.org/color-convert/download/color-convert-1.9.3.tgz",
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
},
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npm.taobao.org/color-name/download/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npm.taobao.org/commander/download/commander-2.20.3.tgz?cache=0&sync_timestamp=1598576050587&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.20.3.tgz",
"integrity": "sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=",
"dev": true
},
"commondir": {
"version": "1.0.1",
"resolved": "https://registry.npm.taobao.org/commondir/download/commondir-1.0.1.tgz",
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npm.taobao.org/escape-string-regexp/download/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
"dev": true
},
"estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npm.taobao.org/estree-walker/download/estree-walker-1.0.1.tgz",
"integrity": "sha1-MbxdYSyWtwQQa0d+bdXYqhOMtwA=",
"dev": true
},
"find-cache-dir": {
"version": "3.3.1",
"resolved": "https://registry.npm.taobao.org/find-cache-dir/download/find-cache-dir-3.3.1.tgz?cache=0&sync_timestamp=1583734806517&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffind-cache-dir%2Fdownload%2Ffind-cache-dir-3.3.1.tgz",
"integrity": "sha1-ibM/rUpGcNqpT4Vff74x1thP6IA=",
"dev": true,
"requires": {
"commondir": "^1.0.1",
"make-dir": "^3.0.2",
"pkg-dir": "^4.1.0"
}
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npm.taobao.org/find-up/download/find-up-4.1.0.tgz",
"integrity": "sha1-l6/n1s3AvFkoWEt8jXsW6KmqXRk=",
"dev": true,
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
}
},
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npm.taobao.org/fs-extra/download/fs-extra-8.1.0.tgz?cache=0&sync_timestamp=1591229972229&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffs-extra%2Fdownload%2Ffs-extra-8.1.0.tgz",
"integrity": "sha1-SdQ8RaiM2Wd2aMt74bRu/bjS4cA=",
"dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npm.taobao.org/fsevents/download/fsevents-2.1.3.tgz",
"integrity": "sha1-+3OHA66NL5/pAMM4Nt3r7ouX8j4=",
"dev": true,
"optional": true
},
"graceful-fs": {
"version": "4.2.4",
"resolved": "https://registry.npm.taobao.org/graceful-fs/download/graceful-fs-4.2.4.tgz",
"integrity": "sha1-Ila94U02MpWMRl68ltxGfKB6Kfs=",
"dev": true
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npm.taobao.org/has-flag/download/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"dev": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/js-tokens/download/js-tokens-4.0.0.tgz",
"integrity": "sha1-GSA/tZmR35jjoocFDUZHzerzJJk=",
"dev": true
},
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/jsonfile/download/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"dev": true,
"requires": {
"graceful-fs": "^4.1.6"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npm.taobao.org/locate-path/download/locate-path-5.0.0.tgz",
"integrity": "sha1-Gvujlq/WdqbUJQTQpno6frn2KqA=",
"dev": true,
"requires": {
"p-locate": "^4.1.0"
}
},
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597336017469&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz",
"integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=",
"dev": true
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npm.taobao.org/make-dir/download/make-dir-3.1.0.tgz?cache=0&sync_timestamp=1587567875186&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmake-dir%2Fdownload%2Fmake-dir-3.1.0.tgz",
"integrity": "sha1-QV6WcEazp/HRhSd9hKpYIDcmoT8=",
"dev": true,
"requires": {
"semver": "^6.0.0"
},
"dependencies": {
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npm.taobao.org/semver/download/semver-6.3.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-6.3.0.tgz",
"integrity": "sha1-7gpkyK9ejO6mdoexM3YeG+y9HT0=",
"dev": true
}
}
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npm.taobao.org/merge-stream/download/merge-stream-2.0.0.tgz",
"integrity": "sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A=",
"dev": true
},
"mime": {
"version": "2.4.6",
"resolved": "https://registry.npm.taobao.org/mime/download/mime-2.4.6.tgz?cache=0&sync_timestamp=1590596637243&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime%2Fdownload%2Fmime-2.4.6.tgz",
"integrity": "sha1-5bQHyQ20QvK+tbFiNz0Htpr/pNE=",
"dev": true
},
"opener": {
"version": "1.5.2",
"resolved": "https://registry.npm.taobao.org/opener/download/opener-1.5.2.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fopener%2Fdownload%2Fopener-1.5.2.tgz",
"integrity": "sha1-XTfh81B3udysQwE3InGv3rKhNZg=",
"dev": true
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npm.taobao.org/p-limit/download/p-limit-2.3.0.tgz?cache=0&sync_timestamp=1594559666231&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fp-limit%2Fdownload%2Fp-limit-2.3.0.tgz",
"integrity": "sha1-PdM8ZHohT9//2DWTPrCG2g3CHbE=",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npm.taobao.org/p-locate/download/p-locate-4.1.0.tgz",
"integrity": "sha1-o0KLtwiLOmApL2aRkni3wpetTwc=",
"dev": true,
"requires": {
"p-limit": "^2.2.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npm.taobao.org/p-try/download/p-try-2.2.0.tgz",
"integrity": "sha1-yyhoVA4xPWHeWPr741zpAE1VQOY=",
"dev": true
},
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/path-exists/download/path-exists-4.0.0.tgz",
"integrity": "sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=",
"dev": true
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npm.taobao.org/path-parse/download/path-parse-1.0.6.tgz",
"integrity": "sha1-1i27VnlAXXLEc37FhgDp3c8G0kw=",
"dev": true
},
"picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npm.taobao.org/picomatch/download/picomatch-2.2.2.tgz",
"integrity": "sha1-IfMz6ba46v8CRo9RRupAbTRfTa0=",
"dev": true
},
"pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npm.taobao.org/pkg-dir/download/pkg-dir-4.2.0.tgz",
"integrity": "sha1-8JkTPfft5CLoHR2ESCcO6z5CYfM=",
"dev": true,
"requires": {
"find-up": "^4.0.0"
}
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npm.taobao.org/randombytes/download/randombytes-2.1.0.tgz",
"integrity": "sha1-32+ENy8CcNxlzfYpE0mrekc9Tyo=",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npm.taobao.org/resolve/download/resolve-1.17.0.tgz",
"integrity": "sha1-sllBtUloIxzC0bt2p5y38sC/hEQ=",
"dev": true,
"requires": {
"path-parse": "^1.0.6"
}
},
"rollup": {
"version": "2.28.1",
"resolved": "https://registry.npm.taobao.org/rollup/download/rollup-2.28.1.tgz?cache=0&sync_timestamp=1600668802635&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frollup%2Fdownload%2Frollup-2.28.1.tgz",
"integrity": "sha1-zu3KPNsBPC+o8i+ViinCAzaBWeo=",
"dev": true,
"requires": {
"fsevents": "~2.1.2"
}
},
"rollup-plugin-serve": {
"version": "1.0.4",
"resolved": "https://registry.npm.taobao.org/rollup-plugin-serve/download/rollup-plugin-serve-1.0.4.tgz",
"integrity": "sha1-F2ZZdBhfkAfsrrCDXE6JYS4E0jQ=",
"dev": true,
"requires": {
"mime": ">=2.4.6",
"opener": "1"
}
},
"rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npm.taobao.org/rollup-plugin-terser/download/rollup-plugin-terser-7.0.2.tgz?cache=0&sync_timestamp=1599268471748&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frollup-plugin-terser%2Fdownload%2Frollup-plugin-terser-7.0.2.tgz",
"integrity": "sha1-6Pu6SGmYGy3DWufopQLVxsBNMk0=",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"jest-worker": "^26.2.1",
"serialize-javascript": "^4.0.0",
"terser": "^5.0.0"
},
"dependencies": {
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/has-flag/download/has-flag-4.0.0.tgz",
"integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=",
"dev": true
},
"jest-worker": {
"version": "26.3.0",
"resolved": "https://registry.npm.taobao.org/jest-worker/download/jest-worker-26.3.0.tgz?cache=0&sync_timestamp=1597057408381&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjest-worker%2Fdownload%2Fjest-worker-26.3.0.tgz",
"integrity": "sha1-fIqX5PQ2S08F7YvKjKDCTeCRhx8=",
"dev": true,
"requires": {
"@types/node": "*",
"merge-stream": "^2.0.0",
"supports-color": "^7.0.0"
}
},
"serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/serialize-javascript/download/serialize-javascript-4.0.0.tgz?cache=0&sync_timestamp=1599740699862&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fserialize-javascript%2Fdownload%2Fserialize-javascript-4.0.0.tgz",
"integrity": "sha1-tSXhI4SJpez8Qq+sw/6Z5mb0sao=",
"dev": true,
"requires": {
"randombytes": "^2.1.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npm.taobao.org/supports-color/download/supports-color-7.2.0.tgz?cache=0&sync_timestamp=1598611708628&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-7.2.0.tgz",
"integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"rollup-plugin-typescript2": {
"version": "0.27.3",
"resolved": "https://registry.npm.taobao.org/rollup-plugin-typescript2/download/rollup-plugin-typescript2-0.27.3.tgz",
"integrity": "sha1-zZRVrAJtMlsgxXKNLMVKCKdxtos=",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"find-cache-dir": "^3.3.1",
"fs-extra": "8.1.0",
"resolve": "1.17.0",
"tslib": "2.0.1"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npm.taobao.org/safe-buffer/download/safe-buffer-5.2.1.tgz?cache=0&sync_timestamp=1589129010497&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsafe-buffer%2Fdownload%2Fsafe-buffer-5.2.1.tgz",
"integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=",
"dev": true
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npm.taobao.org/source-map/download/source-map-0.6.1.tgz",
"integrity": "sha1-dHIq8y6WFOnCh6jQu95IteLxomM=",
"dev": true
},
"source-map-support": {
"version": "0.5.19",
"resolved": "https://registry.npm.taobao.org/source-map-support/download/source-map-support-0.5.19.tgz",
"integrity": "sha1-qYti+G3K9PZzmWSMCFKRq56P7WE=",
"dev": true,
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npm.taobao.org/supports-color/download/supports-color-5.5.0.tgz?cache=0&sync_timestamp=1598611708628&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-5.5.0.tgz",
"integrity": "sha1-4uaaRKyHcveKHsCzW2id9lMO/I8=",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
},
"terser": {
"version": "5.3.2",
"resolved": "https://registry.npm.taobao.org/terser/download/terser-5.3.2.tgz?cache=0&sync_timestamp=1600354856380&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fterser%2Fdownload%2Fterser-5.3.2.tgz",
"integrity": "sha1-9L6pDrkpRbKgKM7veRgbm7WG568=",
"dev": true,
"requires": {
"commander": "^2.20.0",
"source-map": "~0.6.1",
"source-map-support": "~0.5.12"
}
},
"to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npm.taobao.org/to-fast-properties/download/to-fast-properties-2.0.0.tgz?cache=0&sync_timestamp=1580550347606&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fto-fast-properties%2Fdownload%2Fto-fast-properties-2.0.0.tgz",
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
"dev": true
},
"tslib": {
"version": "2.0.1",
"resolved": "https://registry.npm.taobao.org/tslib/download/tslib-2.0.1.tgz?cache=0&sync_timestamp=1596754132993&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftslib%2Fdownload%2Ftslib-2.0.1.tgz",
"integrity": "sha1-QQ6w0RPltjVkkO7HSWA3JbAhtD4=",
"dev": true
},
"typescript": {
"version": "4.0.3",
"resolved": "https://registry.npm.taobao.org/typescript/download/typescript-4.0.3.tgz?cache=0&sync_timestamp=1601029293027&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftypescript%2Fdownload%2Ftypescript-4.0.3.tgz",
"integrity": "sha1-FTu9Ro7wdyXB35x36LRT+NNqu6U=",
"dev": true
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npm.taobao.org/universalify/download/universalify-0.1.2.tgz",
"integrity": "sha1-tkb2m+OULavOzJ1mOcgNwQXvqmY=",
"dev": true
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@mattuy/text-frame",
"version": "0.0.1",
"private": false,
"description": "a javascript tool to split long text into pages, with typesetting prohibition processed and unicode full support.\n一个将长文本分成若干页的js工具,处理排版禁则,支持Unicode",
"main": "index.js",
"directories": {
"example": "example"
},
"scripts": {
"build": "rollup -c rollup.config.js --environment NODE_ENV:development",
"dist": "rollup -c rollup.config.js --environment NODE_ENV:production",
"debug": "rollup -c rollup.config.js --environment NODE_ENV:development --watch",
"install": "npm run build && npm run dist",
"example": "rollup -c rollup.config.js --environment NODE_ENV:development --environment SERVE"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mattuylee/text-frame.git"
},
"keywords": [
"text pagination",
"typesetting",
"canvas",
"Chinese",
"文本排版",
"排版禁则",
"分页",
"中文"
],
"author": "mattuy <mattuylee@outlook.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/mattuylee/text-frame/issues"
},
"homepage": "https://github.com/mattuylee/text-frame#readme",
"devDependencies": {
"@rollup/plugin-babel": "^5.2.1",
"rollup": "^2.28.1",
"rollup-plugin-serve": "^1.0.4",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.27.3",
"typescript": "^4.0.3"
}
}
+42
View File
@@ -0,0 +1,42 @@
import * as path from 'path';
import { terser } from "rollup-plugin-terser";
import typescript from 'rollup-plugin-typescript2';
import serve from 'rollup-plugin-serve';
const devMode = process.env.NODE_ENV === 'development';
const shouldServe = !!process.env.SERVE;
const filename = 'text-frame' + (devMode ? '' : '-min') + '.js';
const plugins = [
typescript({
clean: true,
tsconfig: './tsconfig.json'
}),
!devMode && terser(),
shouldServe && serve({
open: true,
contentBase: './',
openPage: '/example/index.html',
port: 5000
})
].filter(Boolean);
export default {
input: 'src/text-frame.ts',
output: [
{
file: path.resolve('dist', 'esm', filename),
format: 'esm',
sourcemap: devMode
},
{
file: path.resolve('dist', 'umd', filename),
format: 'umd',
name: 'TextFrame',
sourcemap: devMode
}
],
plugins: plugins,
watch: {
include: ['src/**/*', 'example/*']
}
}
+145
View File
@@ -0,0 +1,145 @@
export enum TextAlign {
center = 'center',
start = 'start',
end = 'end',
left = 'left',
right = 'right',
justify = 'justify'
}
export interface BaseOptions {
color?: string
rtl?: boolean
trim?: boolean
fontFamily?: string
fontWeight?: string | number
fontSize?: number
lineHeight?: number
textIndent?: number
textAlign?: TextAlign | 'center' | 'start' | 'end' | 'left' | 'right' | 'justify'
textAlignLast?: TextAlign | 'center' | 'start' | 'end' | 'left' | 'right' | 'justify'
margin?: number | Margin
marginCollapse?: boolean
noHeadMargin?: boolean
}
export interface FragmentOptions extends BaseOptions {
text: string
}
export interface Margin {
left?: number
right?: number
top?: number
bottom?: number
}
export interface FrameOptions extends BaseOptions {
viewWidth?: number
viewHeight?: number
canvasHeight?: number
canvasWidth?: number
lineStartProhibitedMarks?: string
lineEndProhibitedMarks?: string
unbreakableRule?: RegExp
fragments: FragmentOptions[]
}
export interface FrameLine {
offset: { x: number, y: number }
fragmentOptions: FragmentOptions
isLastLine: boolean
chars: string[]
}
export interface TextFrame {
options: FrameOptions
lines: FrameLine[]
}
const
defaultOptionBase: BaseOptions = {
color: '#000000',
fontFamily: 'serif',
fontWeight: 'normal',
fontSize: 16,
rtl: false,
margin: 0,
marginCollapse: true,
noHeadMargin: false,
textIndent: 0,
textAlign: 'justify',
textAlignLast: 'start'
}
, defaultOptions: FrameOptions = {
...defaultOptionBase,
viewWidth: 300,
viewHeight: 150,
lineStartProhibitedMarks: `、,..。:;!?'"」』”’)]})】〗〕]}》〉–~~—·.‧•・//`,
lineEndProhibitedMarks: `「『“‘([{(【〖〔[〔《〈/`,
unbreakableRule: /──|……|[\w\d]+/,
fragments: null
};
function resolveBaseOptions(options: BaseOptions) {
// we don't cache devicePixelRatio globally, since it may change
const dpi = devicePixelRatio || 1;
const resolvedOptions: BaseOptions = {
...defaultOptionBase,
...(options || Object())
};
resolvedOptions.textIndent = (resolvedOptions.textIndent | 0) * dpi;
if (resolvedOptions.fontSize > 0) {
resolvedOptions.fontSize *= dpi;
}
else {
resolvedOptions.fontSize = defaultOptions.fontSize * dpi;
}
if (resolvedOptions.lineHeight > 0) {
resolvedOptions.lineHeight *= dpi;
}
else {
resolvedOptions.lineHeight = resolvedOptions.fontSize * 1.5;
}
if (!(resolvedOptions.textAlign in TextAlign)) {
resolvedOptions.textAlign = TextAlign.start;
}
if (typeof resolvedOptions.margin === 'object' && resolvedOptions !== null) {
for (const s of ['left', 'right', 'top', 'bottom']) {
resolvedOptions.margin[s] = (resolvedOptions.margin[s] | 0) * dpi;
}
}
else {
const margin = (resolvedOptions.margin as number | 0) * dpi;
resolvedOptions.margin = {
left: margin,
right: margin,
top: margin,
bottom: margin
};
}
return resolvedOptions;
}
export function resolveOptions(options: FrameOptions) {
const dpi = devicePixelRatio
, resolvedOptions: FrameOptions = {
...defaultOptions,
...resolveBaseOptions(options)
};
resolvedOptions.canvasWidth = options.canvasWidth || resolvedOptions.viewWidth * dpi;
resolvedOptions.canvasHeight = options.canvasHeight || resolvedOptions.viewHeight * dpi;
return resolvedOptions;
}
export function resolveFragmentOptions(slideOps: FrameOptions, fragOps: FragmentOptions) {
// margin is not inherited from frame options
const { margin: _, ...fallback } = slideOps;
// for ignored config, fallback to global option, then fallback to default option
let resolvedOptions: FragmentOptions = { ...fallback, ...fragOps };
resolvedOptions.text = resolvedOptions.text || '';
resolvedOptions.text =
resolvedOptions.text.replace('\r\n', '\n').replace('\r', '\n');
resolvedOptions = resolveBaseOptions(resolvedOptions) as FragmentOptions;
if (resolvedOptions.trim) {
resolvedOptions.text = resolvedOptions.text.trim();
}
return resolvedOptions;
}
+333
View File
@@ -0,0 +1,333 @@
import { TextFrame, FrameLine, FragmentOptions, Margin, FrameOptions, TextAlign, resolveOptions, resolveFragmentOptions } from "./options";
const ctx = document.createElement('canvas').getContext('2d');
export function computeTextFrames(options: FrameOptions) {
const ops = resolveOptions(options || {} as any)
, frames: TextFrame[] = []
, frameMargin = ops.margin as Margin
, maxFrameX = ops.canvasWidth - 1 - frameMargin.right
, maxFrameY = ops.canvasHeight - 1 - frameMargin.bottom;
let cursor = { x: 0, y: 0 }
, textIndex = 0
, maxLineX = 0
, remainingMargin = 0
, previousMarginCollapse = false
, currFragment: FragmentOptions
, currentText: string
, currentLine: FrameLine
, currentFrame: TextFrame;
/**
* get next char.
* JavaScript encode string with UTF-16, where one unicode code point may be
* encoded with two chars(such as emoji), so we need to deal with that case.
* Refrence(English): @see https://en.wikipedia.org/wiki/UTF-16
* Refrence(Chinese): @see https://zh.wikipedia.org/wiki/UTF-16
*/
function nextChar() {
if (!currentText) { return ''; }
const codePoint = currentText.codePointAt(textIndex);
if (codePoint === undefined) { return ''; }
const charLen = String.fromCodePoint(codePoint).length;
return currentText.slice(textIndex, textIndex + charLen);
}
// move cursor to a new frame
function newFrame() {
if (currentFrame) {
finishFrame();
}
currentFrame = {
options: ops,
lines: []
}
currentLine = null;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
cursor = { x: frameMargin.left, y: frameMargin.top };
};
// move cursor to head of a new line
function newLine() {
const fragOps = currFragment;
if (!currentFrame) { newFrame(); }
finishLine();
if (currentFrame.lines.length === 0) {
// if there is no line in current frame, just start a new line without
// checking available height, since a frame must be high enouth for one
// line
cursor = {
x: frameMargin.left + fragOps.margin['left'],
y: frameMargin.top
}
}
else {
// cursor.y are based on start of text box, but maxFrameY is max y which
// the end of text box can reach, so we need to minus an extra lineHeight
const availableHeight = maxFrameY - (cursor.y + fragOps.lineHeight);
if (availableHeight < fragOps.lineHeight) {
newFrame();
// we don't plus the top margin of current fragment, because fragment
// verticle margin should only be computed at the starting of fragment.
cursor = {
x: frameMargin.left + fragOps.margin['left'],
y: frameMargin.top
};
}
else {
cursor.x = frameMargin.left + fragOps.margin['left'];
cursor.y += fragOps.lineHeight;
}
}
currentLine = {
chars: [],
isLastLine: false,
fragmentOptions: fragOps,
offset: { ...cursor },
}
};
function finishLine() {
if (currentLine) {
// if a line exist currently, commit it
currentFrame.lines.push(currentLine);
currentLine = null;
}
}
function finishLastLine() {
if (currentLine) {
currentLine.isLastLine = true;
}
finishLine();
}
// flush work on current frame
function finishFrame() {
if (currentFrame) {
if (currentLine) { finishLine(); }
frames.push(currentFrame);
currentFrame = null;
}
}
for (let fragOps of (ops.fragments || [])) {
// flush work on previous fragment
finishLastLine();
fragOps = currFragment = resolveFragmentOptions(options, fragOps);
ctx.font = fragOps.fontWeight + ' '
+ fragOps.fontSize + 'px '
+ fragOps.fontFamily;
newLine();
if (currentFrame.lines.length === 0) {
// don't let new frame affected by remaining margin from previous frame
remainingMargin = 0;
}
if (fragOps.marginCollapse && previousMarginCollapse) {
remainingMargin = Math.max(fragOps.margin['top'], remainingMargin);
}
else {
remainingMargin += fragOps.margin['top'];
}
if (currentFrame.lines.length > 0) {
if (cursor.y + remainingMargin + fragOps.lineHeight > maxFrameY + 1) {
// there is no enough space on current frame, get a new one
currentLine = null;
finishFrame();
newLine();
}
else {
cursor.y += remainingMargin;
}
}
// not "else if". we check, or maybe re-check whether current frame is
// empty, because current frame may be resetted above
if (currentFrame.lines.length === 0 && !fragOps.noHeadMargin) {
// current frame is empty, we don't create new frame anyway, so we can
// skip checking space
cursor.y = frameMargin.top + fragOps.margin['top'];
}
else {
// if current frame is not empty, we have processed above; if
// fragOps.noHeadMargin is true, the cursor is set correctly when we
// create current frame. so there is nothing to do.
}
if (fragOps.textIndent > 0) {
cursor.x += fragOps.textIndent;
}
// we moved cursor, and the slot of line start may have changed, reset it
currentLine.offset.x = cursor.x;
currentLine.offset.y = cursor.y;
maxLineX = maxFrameX - fragOps.margin['right'];
currentText = fragOps.text;
textIndex = 0;
while (textIndex < currentText.length) {
let currentChar = nextChar()
, currentCharLength = currentChar.length
, currentCharWidth = ctx.measureText(currentChar).width
, availableWidth = maxLineX + 1 - cursor.x;
if (currentChar === '\n') {
// current char is a carrige return, just move cusor to next line.
// even if the last char of current line if a prohibition of line end,
// we don't process it, since the paragraph has ended.
finishLastLine();
newLine();
currentChar = null;
currentCharWidth = 0;
// we don't indent text since it's not the begining of a paragraph.
}
else if (availableWidth < currentCharWidth) {
// there is no enough space to place current char, we need to turn to a
// new line.
// but before that, we have to process the prohibition of line start
// and line end.
let lineStartChar = currentChar, lineEndChar, newIndex = textIndex;
for (let i = currentLine.chars.length - 1; i >= 0; --i) {
lineEndChar = currentLine.chars[i];
// we check whether there is a prohibition, if true, try to fix.
// if a prohibition found, we try to fix it through moving the
// line-end char of current line to the start of next line. then
// check, repeat.
// if we can't fix when current line has only one char left, just do
// nothing as if there was no prohibition found.
if (!ops.lineStartProhibitedMarks.includes(lineStartChar)
&& !ops.lineEndProhibitedMarks.includes(lineEndChar)
&& !ops.unbreakableRule.test(lineEndChar + lineStartChar)) {
// fix
currentChar = lineStartChar;
currentCharLength = currentChar.length;
currentCharWidth = ctx.measureText(currentChar).width;
textIndex = newIndex;
currentLine.chars = currentLine.chars.slice(0, i + 1);
break;
}
newIndex -= lineStartChar.length;
lineStartChar = lineEndChar;
}
newLine();
}
if (currentChar) {
currentLine.chars.push(currentChar);
}
cursor.x += currentCharWidth;
textIndex += currentCharLength;
}
remainingMargin = fragOps.margin['bottom'];
previousMarginCollapse = fragOps.marginCollapse;
}
finishLastLine();
finishFrame();
return frames;
}
// prepare canvas to render text.
function prepareCanvas(
context: CanvasRenderingContext2D,
fragOptions: FragmentOptions,
line: FrameLine) {
const mergedOptions = fragOptions;
if (mergedOptions) {
context.font = mergedOptions.fontWeight + ' '
+ mergedOptions.fontSize + 'px '
+ mergedOptions.fontFamily;
context.fillStyle = mergedOptions.color;
// since context.direction is an experimental technology, we cannot rely on it
context.direction = 'ltr';
let textAlign = mergedOptions.textAlign;
if (line.isLastLine && mergedOptions.textAlign === 'justify') {
textAlign = mergedOptions.textAlignLast;
}
if (textAlign === 'start') {
context.textAlign = mergedOptions.rtl ? 'right' : 'left';
}
else if (textAlign === 'end') {
context.textAlign = mergedOptions.rtl ? 'left' : 'right';
}
else if (['center', 'left', 'right'].includes(textAlign)) {
context.textAlign = textAlign as any;
}
else {
context.textAlign = 'left';
}
context.textBaseline = 'middle';
}
}
// finish work on current line
function renderLine(
context: CanvasRenderingContext2D,
options: FrameOptions,
line: FrameLine) {
if (line) {
const fragOps = line.fragmentOptions
, chars = fragOps.rtl
? line.chars.slice(0).reverse()
: line.chars
, frameMargin = options.margin as Margin
, fragMargin = fragOps.margin as Margin;
// draw text
prepareCanvas(context, fragOps, line);
let { x: offsetX, y: offsetY } = line.offset
, isJustifyMode = fragOps.textAlign === TextAlign.justify
, textIndent = offsetX - frameMargin.left - fragOps.margin['left'];
// offsetY is based on top of text box, and context.textBaseline is
// 'middle', so plus a half line height
offsetY += fragOps.lineHeight / 2;
if (isJustifyMode && line.isLastLine && fragOps.textAlignLast !== TextAlign.justify) {
isJustifyMode = false;
}
if (isJustifyMode) {
const contentWidth = context.measureText(chars.join('')).width
, remainWidth = (options.canvasWidth - contentWidth
- frameMargin.left - frameMargin.right - fragMargin.left
- fragMargin.right - textIndent)
, gap = remainWidth / (chars.length - 1);
if (fragOps.rtl) {
// initially, we assume using left-to-right mode, but it's right-to-left
// mode now, we'll reverse the text and recompute offsetX, and we have
// to "move" text indention to right
offsetX -= textIndent;
}
for (let i = 0; i < chars.length; ++i) {
context.fillText(chars[i], Math.round(offsetX), offsetY);
offsetX += context.measureText(chars[i]).width + gap;
}
}
else {
// we use context.textAlign not fragOps.textAlign, because the first one
// has been normalized when preparing canvas
if (context.textAlign === 'center') {
offsetX = context.canvas.width / 2;
}
else if (context.textAlign === 'right') {
offsetX = (options.canvasWidth - frameMargin.right
- fragOps.margin['right'] - textIndent);
}
context.fillText(chars.join(''), offsetX, offsetY);
}
line = null;
}
}
export function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame, clear?: boolean) {
if (!context) {
throw Error("error: text-frame - must provide drawing context!");
}
if (!frame) {
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
return;
}
const canvasWidth = Math.round(frame.options.canvasWidth)
, canvasHeight = Math.round(frame.options.canvasHeight);
if (clear
|| context.canvas.width !== canvasWidth
|| context.canvas.height !== canvasHeight) {
context.canvas.width = canvasWidth
context.canvas.height = canvasHeight
}
for (const line of frame.lines) {
renderLine(context, frame.options, line);
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"lib": ["ES5", "ES2015", "DOM"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": false, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
// "skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"exclude": [
"node_modules/**/*"
]
}