From ff1249767309bf9a5b2ecec3ce3efa34fc9e37df Mon Sep 17 00:00:00 2001 From: mattuy Date: Mon, 5 Oct 2020 20:26:29 +0800 Subject: [PATCH] first version --- .gitignore | 2 + README.md | 134 ++++++++++++ docs/en/README-EN.md | 124 +++++++++++ docs/en/options.md | 130 ++++++++++++ docs/zh/example.html | 0 docs/zh/options.md | 129 ++++++++++++ esm.d.ts | 8 + esm.js | 6 + example/index.html | 129 ++++++++++++ index.d.ts | 16 ++ index.js | 12 ++ package-lock.json | 484 +++++++++++++++++++++++++++++++++++++++++++ package.json | 45 ++++ rollup.config.js | 42 ++++ src/options.ts | 145 +++++++++++++ src/text-frame.ts | 333 +++++++++++++++++++++++++++++ tsconfig.json | 72 +++++++ 17 files changed, 1811 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/en/README-EN.md create mode 100644 docs/en/options.md create mode 100644 docs/zh/example.html create mode 100644 docs/zh/options.md create mode 100644 esm.d.ts create mode 100644 esm.js create mode 100644 example/index.html create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 src/options.ts create mode 100644 src/text-frame.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0771922 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +/dist \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..261d4bc --- /dev/null +++ b/README.md @@ -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 + + +``` + +配置说明请参考[配置项](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 \ No newline at end of file diff --git a/docs/en/README-EN.md b/docs/en/README-EN.md new file mode 100644 index 0000000..4f4ee2c --- /dev/null +++ b/docs/en/README-EN.md @@ -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 + + +``` +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 \ No newline at end of file diff --git a/docs/en/options.md b/docs/en/options.md new file mode 100644 index 0000000..59f9cb4 --- /dev/null +++ b/docs/en/options.md @@ -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.width,default 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 diff --git a/docs/zh/example.html b/docs/zh/example.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/zh/options.md b/docs/zh/options.md new file mode 100644 index 0000000..fdae75a --- /dev/null +++ b/docs/zh/options.md @@ -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` +* 默认值: `''` +* 说明: 该文本段落要绘制的文本内容。 diff --git a/esm.d.ts b/esm.d.ts new file mode 100644 index 0000000..f7ccffa --- /dev/null +++ b/esm.d.ts @@ -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; diff --git a/esm.js b/esm.js new file mode 100644 index 0000000..f6cfd89 --- /dev/null +++ b/esm.js @@ -0,0 +1,6 @@ +import { computeTextFrames, renderFrame } from './dist/esm/text-frame-min'; + +export { + computeTextFrames, + renderFrame +} diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..1c2425e --- /dev/null +++ b/example/index.html @@ -0,0 +1,129 @@ + + + + + + + Test + + + + + +
+
+ + +
+
+ + + + 0 / 0 +
+
+ + + + + \ No newline at end of file diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..4f070ba --- /dev/null +++ b/index.d.ts @@ -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 +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..65dc433 --- /dev/null +++ b/index.js @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c755bd --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b4f4d7 --- /dev/null +++ b/package.json @@ -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 ", + "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" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..5aff873 --- /dev/null +++ b/rollup.config.js @@ -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/*'] + } +} \ No newline at end of file diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..a496825 --- /dev/null +++ b/src/options.ts @@ -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; +} \ No newline at end of file diff --git a/src/text-frame.ts b/src/text-frame.ts new file mode 100644 index 0000000..11d910f --- /dev/null +++ b/src/text-frame.ts @@ -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); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aee0d40 --- /dev/null +++ b/tsconfig.json @@ -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/**/*" + ] +}