├── .gitignore
├── .npmignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
└── favicon.svg
├── src
├── App.css
├── App.tsx
├── assets
│ ├── icon_img_enlarge_default.svg
│ ├── icon_img_narrow_default.svg
│ ├── icon_img_normal_default.svg
│ ├── icon_img_rotate+90_default.svg
│ ├── icon_img_rotate-90_default.svg
│ ├── outline-left.svg
│ ├── outline-right.svg
│ └── react.svg
├── components
│ ├── FilePreview
│ │ ├── ImagesViewer
│ │ │ ├── index.module.less
│ │ │ └── index.tsx
│ │ ├── PDFViewer
│ │ │ ├── index.module.less
│ │ │ ├── index.tsx
│ │ │ └── types.ts
│ │ ├── Pagination
│ │ │ ├── index.module.less
│ │ │ └── index.tsx
│ │ ├── Toolbar
│ │ │ ├── index.module.less
│ │ │ └── index.tsx
│ │ ├── index.module.less
│ │ ├── index.tsx
│ │ └── types.ts
│ ├── JsonView
│ │ └── index.tsx
│ ├── Loading
│ │ ├── index.module.less
│ │ └── index.tsx
│ ├── MarkLayer
│ │ ├── SvgRect
│ │ │ ├── Text.tsx
│ │ │ ├── index.module.less
│ │ │ └── index.tsx
│ │ ├── helpers.ts
│ │ ├── index.module.less
│ │ └── index.tsx
│ ├── RadioGroup
│ │ ├── index.module.less
│ │ └── index.tsx
│ └── ResultView
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── KeyValueList.module.less
│ │ ├── KeyValueList.tsx
│ │ ├── KeyValueTable.module.less
│ │ ├── KeyValueTable.tsx
│ │ ├── index.module.less
│ │ └── index.tsx
├── examples
│ ├── ImageExample.tsx
│ ├── PDFExample.tsx
│ ├── data.ts
│ ├── image_example.json
│ └── pdf_example.json
├── hooks
│ ├── useContentLinkage.ts
│ ├── useFrameSetState.ts
│ ├── useLoadPDFLib.ts
│ ├── useMarkTool.ts
│ ├── usePDFMarkLayer.ts
│ └── usePreviewTool.tsx
├── index.css
├── index.ts
├── main.tsx
├── types
│ ├── common.ts
│ ├── global.d.ts
│ └── keyVal.ts
├── utils
│ ├── browser.ts
│ ├── debounce.ts
│ ├── dom.ts
│ ├── object.ts
│ ├── throttle.ts
│ ├── transformers.ts
│ └── uuid.ts
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.tsbuildinfo
└── vite.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # 开发工具配置
2 | .git/
3 | .gitignore
4 | .eslintrc*
5 | .prettierrc*
6 | .editorconfig
7 | .vscode/
8 | .idea/
9 | *.log
10 |
11 | # 开发依赖
12 | node_modules/
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 |
17 | # 构建工具配置
18 | vite.config.ts
19 | tsconfig.json
20 | tsconfig.*.json
21 | *.config.js
22 | *.config.ts
23 |
24 | # 源代码和开发文件
25 | src/
26 | public/
27 | index.html
28 | *.test.*
29 | *.spec.*
30 | __tests__/
31 | test/
32 | tests/
33 | coverage/
34 |
35 | # 文档和贡献指南
36 | CONTRIBUTING.md
37 | CHANGELOG.md
38 | *.md
39 | !README.md
40 |
41 | # 其他
42 | .DS_Store
43 | .env*
44 | *.local
45 | .qodo/
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 | # 贡献指南
3 |
4 | 感谢你对本项目感兴趣并希望贡献代码!为了确保贡献的顺利进行,请遵循以下流程和指南。
5 |
6 | ## 如何贡献
7 |
8 | ### 报告问题
9 |
10 | 1. 确保问题尚未被报告或解决。
11 | 2. 提供尽可能详细的信息,包括复现步骤、期望行为和实际行为。
12 | 3. 如果适用,附上相关截图或错误日志。
13 |
14 | ### 提交代码
15 |
16 | 1. **Fork 本仓库**
17 | 在 GitHub 上点击 `Fork` 按钮,将此仓库复制到你的 GitHub 帐户中。
18 |
19 | 2. **创建特性分支**
20 | 在你的 fork 仓库中,创建一个新的分支用于开发。分支命名建议遵循以下格式:
21 |
22 | ```bash
23 | git checkout -b feature/描述你的功能
24 | git checkout -b fix/描述你的修复
25 | ```
26 |
27 | 3. **编写代码和测试**
28 | - 编写清晰且符合项目风格的代码。
29 | - 尽量编写测试代码,以确保代码的正确性和稳定性。
30 |
31 | 4. **提交代码**
32 | 使用简洁且有描述性的提交信息提交代码。推荐的提交信息格式:
33 |
34 | ```
35 | [类型] 简要描述
36 |
37 | - 详细描述(可选)
38 | ```
39 |
40 | 常见的类型包括:
41 | - `feat`: 新功能
42 | - `fix`: 修复问题
43 | - `docs`: 修改文档
44 | - `style`: 代码风格改进(不影响代码运行)
45 | - `refactor`: 代码重构
46 | - `test`: 添加或修改测试
47 | - `chore`: 其他非代码修改(构建工具、依赖更新等)
48 |
49 | 5. **同步主分支**
50 | 确保你的分支与上游仓库的 `main`(或 `master`)分支保持同步:
51 |
52 | ```bash
53 | git checkout main
54 | git pull upstream main
55 | git checkout feature/你的分支
56 | git rebase main
57 | ```
58 |
59 | 6. **提交 Pull Request**
60 | - 在 GitHub 上提交 Pull Request,选择合适的标题并简要描述你的修改。
61 | - 如果你的代码修改解决了某个 issue,请在 Pull Request 中引用该 issue,例如:`Closes #123`。
62 | - 耐心等待维护者的反馈,并根据需要进行进一步修改。
63 |
64 | ## 代码风格
65 |
66 | - 使用 [ESLint](https://eslint.org/) 进行代码检查,遵循项目的 ESLint 规则。
67 | - 确保所有 TypeScript 文件通过 [TSLint](https://palantir.github.io/tslint/) 检查。
68 |
69 | ## 分支管理
70 |
71 | - `main`(或 `master`)分支为生产分支,请勿直接在此分支提交代码。
72 | - 所有新的功能或修复应在独立的分支上开发,并通过 Pull Request 合并到 `main`(或 `master`)分支。
73 |
74 | ## 开发环境
75 |
76 | 确保你的开发环境满足以下要求:
77 |
78 | - Node.js 版本 >= 18.x
79 | - Yarn 或 npm 最新版本
80 | - TypeScript 版本 >= 4.x
81 |
82 | ## 联系我们
83 |
84 | 如果你有任何疑问或建议,请通过 GitHub Issues 或邮件联系我们。
85 |
86 | 感谢你的贡献!
87 |
88 | ### 使用说明
89 |
90 | - 根据项目的实际情况调整 `分支命名`、`提交信息格式` 等部分。
91 | - 如果有具体的代码风格指南或开发环境要求,可以详细说明。
92 | - 请确保贡献者了解如何在本地测试他们的修改。
93 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | CC-NC License
2 |
3 | Copyright (c) 2024 Intsig
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, and sublicense the Software,
9 | subject to the following conditions:
10 |
11 | 1. The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | 2. The Software may not be used for commercial purposes. Commercial purposes include,
15 | but are not limited to, selling copies of the Software, incorporating the Software
16 | into a product or service that is sold, or using the Software to generate revenue.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | SOFTWARE.
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Textin OCR Frontend
2 |
3 | 一个用于展示 Textin 识别结果的 React 组件库,支持文件预览、坐标回显和结果展示。
4 |
5 | 目前已支持票据类解析结果(key-value)的展示,具体对应 Textin[票据文字识别](https://www.textin.com/product/textin_bill)如[国内通用票据识别](https://www.textin.com/market/detail/bill_recognize_v2)、[银行回单识别](https://www.textin.com/market/detail/bank_receipts)、[电子承兑汇票识别](https://www.textin.com/market/detail/electr_acceptance_bill)等相关产品识别结果的展示。
6 |
7 | 组件库使用的数据结构是规范后的前端数据结构,使用时需要将 OCR 识别 API 返回的数据转换为前端使用的结构,具体参见本项目[examples](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/src/examples/data.ts),原始数据结构参见 Textin 官网[API 文档](https://www.textin.com/document/bill_recognize_v2)。
8 |
9 | ## 特性
10 |
11 | - 📄 支持图片和 PDF 文件预览
12 | - 🎯 支持文本区域坐标回显和高亮
13 | - 🔄 预览区域和识别结果双向联动
14 | - 📊 支持 JSON 格式结果展示
15 | - 🎨 TODO:可自定义样式和主题
16 |
17 | ## 安装
18 |
19 | 拉取项目
20 |
21 | ```bash
22 | git clone https://github.com/intsig-textin/textin-ocr-frontend.git
23 | ```
24 |
25 | ```bash
26 | npm install textin-ocr-frontend
27 | # 或
28 | yarn add textin-ocr-frontend
29 | ```
30 |
31 | ## 快速开始
32 |
33 | ```tsx
34 | import { FilePreview, ResultView, JsonView } from "textin-ocr-frontend";
35 |
36 | function App() {
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 | ```
49 |
50 | ## 组件说明
51 |
52 | ### 1. FilePreview 文件预览组件
53 |
54 | 文件预览组件,支持 PDF 和图片预览,支持缩放、旋转、分页等功能。
55 |
56 | #### Props
57 |
58 | | 参数 | 说明 | 类型 | 默认值 |
59 | | ---------------- | --------------------------- | ---------------------------------------------------------------------------------------------- | ------ |
60 | | className | 自定义类名 | string | - |
61 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - |
62 | | src | 文件源,支持 PDF 和图片数组 | [PDFSrc](#pdfsrc) \| string[] | - |
63 | | rects | 标注框数据 | [IRectItem](#irectitem)[][] | - |
64 | | pages | 页面数据 | [IPageItem](#ipageitem)[] | - |
65 | | getContainerRef | 获取容器引用 | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | - |
66 | | activeContentId | 当前选中的内容 ID | string | - |
67 | | showMark | 是否显示标注 | boolean | - |
68 | | hasPagination | 是否显示分页 | boolean | - |
69 | | hasToolbar | 是否显示工具栏 | boolean | - |
70 | | toolbarOptions | 工具栏配置 | [ToolbarOptions](#toolbaroptions) | - |
71 | | toolbarStyle | 工具栏样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - |
72 | | loading | 加载中状态 | boolean | - |
73 | | loadingComponent | 自定义加载组件 | ReactNode | - |
74 |
75 | ### 2. ResultView 结果展示组件
76 |
77 | 结果展示组件,支持表格和列表两种展示方式。
78 |
79 | #### Props
80 |
81 | | 参数 | 说明 | 类型 | 默认值 |
82 | | --------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- | ------ |
83 | | className | 自定义类名 | string | - |
84 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - |
85 | | resultList | 结果列表 | [IResultListItem](#iresultlistitem)[] | - |
86 | | getContainerRef | 获取容器引用 | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | - |
87 | | activeContentId | 当前选中的内容 ID | string | - |
88 | | activeParentContentId | 当前选中的内容所属的上级 ID | string | - |
89 | | loading | 加载中状态 | boolean | - |
90 | | loadingComponent | 自定义加载组件 | ReactNode | - |
91 |
92 | ### 3. MarkLayer 标注层组件
93 |
94 | 标注层组件,用于在图片显示标注框。
95 |
96 | #### Props
97 |
98 | | 参数 | 说明 | 类型 | 默认值 |
99 | | --------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ------ |
100 | | className | 自定义类名 | string | - |
101 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - |
102 | | rects | 标注框数据 | [IRectItem](#irectitem)[] | - |
103 | | rate | 渲染比例(渲染宽度/原始宽度) | number | - |
104 | | activeContentId | 当前选中的内容 ID | string | - |
105 | | getContainer | 获取容器元素 | () => HTMLElement \| null \| undefined | - |
106 | | svgAttr | SVG 属性 | [SVGProps](https://react.dev/reference/react-dom/components/common#svg-attributes) | - |
107 | | onMouseDown | 鼠标按下事件 | (e: any) => void | - |
108 |
109 | ### 4. JsonView JSON 展示组件
110 |
111 | JSON 数据展示组件,用于格式化展示 JSON 数据。
112 | 本项目 JSON 数据采用`react-json-view`库渲染,API 保持一致,详细属性可参考其官方文档。
113 |
114 | #### Props
115 |
116 | | 参数 | 说明 | 类型 | 默认值 |
117 | | ------- | ------------------------- | ---------------------------------------------------------------------------------------------- | ------ |
118 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - |
119 | | src | JSON 数据 | any | - |
120 | | ...rest | 其他 react-json-view 属性 | [ReactJsonViewProps](https://github.com/mac-s-g/react-json-view/blob/master/src/js/index.d.ts) | - |
121 |
122 | ## API Interface 定义
123 |
124 | ### PDFSrc
125 |
126 | PDF 文件源配置
127 |
128 | ```typescript
129 | interface DocumentInitParameters {
130 | [key: string]: any;
131 | url?: string | URL;
132 | data?: TypedArray | ArrayBuffer | Array | string;
133 | httpHeaders?: Object;
134 | withCredentials?: boolean;
135 | password?: string;
136 | length?: boolean;
137 | }
138 |
139 | type PDFSrc = DocumentInitParameters;
140 | ```
141 |
142 | ### IRectItem
143 |
144 | 标注框数据
145 |
146 | ```typescript
147 | interface IRectItem {
148 | [key: string]: any;
149 | key?: string;
150 | type?: string;
151 | rect_type?: string;
152 | uid: string;
153 | parent_uid?: string;
154 | content_id: string;
155 | parent_id?: string;
156 | position: number[];
157 | angle?: number;
158 | render_text?: string;
159 | }
160 | ```
161 |
162 | ### IPageItem
163 |
164 | 页面数据
165 |
166 | ```typescript
167 | interface IPageItem {
168 | page_number: number;
169 | duration: number;
170 | ppi: number;
171 | width: number;
172 | height: number;
173 | angle?: number;
174 | }
175 | ```
176 |
177 | ### IResultListItem
178 |
179 | 结果列表项
180 |
181 | ```typescript
182 | interface IResultListItem extends IRectItem {
183 | type: string;
184 | description: string;
185 | no: number;
186 | list: IFieldItem[];
187 | flightList: IFieldItem[][];
188 | page_id?: number;
189 | }
190 | ```
191 |
192 | ### IFieldItem
193 |
194 | 字段项
195 |
196 | ```typescript
197 | interface IFieldItem extends IOriginFieldItem {
198 | uid: string;
199 | parent_uid?: string;
200 | }
201 |
202 | interface IOriginFieldItem {
203 | key: string;
204 | type?: string;
205 | value: string;
206 | description: string;
207 | position: number[];
208 | }
209 | ```
210 |
211 | ### ToolbarOptions
212 |
213 | 工具栏配置
214 |
215 | ```typescript
216 | interface ToolbarOptions {
217 | tools: PreviewToolItem[];
218 | }
219 |
220 | interface PreviewToolItem {
221 | Icon: React.ComponentType;
222 | onClick: () => void;
223 | type: string;
224 | disabled?: boolean;
225 | }
226 | ```
227 |
228 | ### PreviewToolItem
229 |
230 | 工具栏配置项
231 |
232 | ```typescript
233 | interface PreviewToolItem {
234 | Icon: React.ComponentType; // 工具栏图标组件
235 | onClick: () => void; // 点击事件处理函数
236 | type: string; // 工具类型
237 | disabled?: boolean; // 是否禁用
238 | }
239 | ```
240 |
241 | ## Hooks
242 |
243 | ### useContentLinkage
244 |
245 | 用于实现预览区域和识别结果的双向联动。
246 |
247 | ```tsx
248 | const { activeContentId, activeParentContentId, registerLinkage } =
249 | useContentLinkage({
250 | viewContainerRef,
251 | resultContainerRef,
252 | });
253 | ```
254 |
255 | #### 参数
256 |
257 | | 参数 | 类型 | 必填 | 说明 |
258 | | ------------------ | ------------------------------------------------------------------------------------------------------ | ---- | ------------ |
259 | | viewContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览容器引用 |
260 | | resultContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 结果容器引用 |
261 |
262 | #### 返回值
263 |
264 | | 属性 | 类型 | 说明 |
265 | | --------------------- | ---------- | --------------------------- |
266 | | activeContentId | string | 当前选中的内容 ID |
267 | | activeParentContentId | string | 当前选中的内容所属的父级 ID |
268 | | registerLinkage | () => void | 注册联动事件 |
269 |
270 | ### usePDFMarkLayer
271 |
272 | 用于在 PDF 文档上实现标注层功能。
273 |
274 | ```tsx
275 | const { run } = usePDFMarkLayer({
276 | containerRef,
277 | pdfViewerRef,
278 | rects,
279 | pages,
280 | dpi,
281 | activeContentId,
282 | showMark,
283 | });
284 | ```
285 |
286 | #### 参数
287 |
288 | | 参数 | 类型 | 必填 | 说明 |
289 | | --------------- | -------------------------------------------------------------------------------------- | ---- | ----------------- |
290 | | containerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | PDF 容器引用 |
291 | | pdfViewerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 否 | PDF 查看器引用 |
292 | | rects | [IRectItem](#irectitem)[][] | 否 | 标注框数据 |
293 | | pages | [IPageItem](#ipageitem)[] | 是 | 页面数据 |
294 | | dpi | number | 否 | 分辨率 |
295 | | activeContentId | string | 否 | 当前选中的内容 ID |
296 | | showMark | boolean | 否 | 是否显示标注 |
297 |
298 | #### 返回值
299 |
300 | | 属性 | 类型 | 说明 |
301 | | ---- | ---------- | ---------- |
302 | | run | () => void | 运行标注层 |
303 |
304 | ### usePreviewTool
305 |
306 | 用于实现预览工具栏功能,包括缩放、旋转和 1:1 还原。
307 |
308 | ```tsx
309 | const { tools, scale, rotate, position, onMouseDown, onWheel, resizeScale } =
310 | usePreviewTool({
311 | viewContainerRef,
312 | viewRef,
313 | toolbarOptions,
314 | });
315 | ```
316 |
317 | #### 参数
318 |
319 | | 参数 | 类型 | 必填 | 说明 |
320 | | ---------------- | ---------------------------------------------------------------------------------------------- | ---- | ------------ |
321 | | viewContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览容器引用 |
322 | | viewRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览内容引用 |
323 | | toolbarOptions | [ToolbarOptions](#toolbaroptions) | 否 | 工具栏配置 |
324 |
325 | #### 返回值
326 |
327 | | 属性 | 类型 | 说明 |
328 | | ----------- | ------------------------------------- | -------------------- |
329 | | tools | [PreviewToolItem](#previewtoolitem)[] | 工具栏配置项 |
330 | | scale | number | 当前缩放比例 |
331 | | rotate | number | 当前旋转角度 |
332 | | position | { x: number; y: number } | 当前位移位置 |
333 | | onMouseDown | (event: any) => void | 鼠标按下事件处理函数 |
334 | | onWheel | (event: WheelEvent) => void | 滚轮事件处理函数 |
335 | | resizeScale | () => void | 重置缩放比例函数 |
336 |
337 | ## 示例
338 |
339 | #### [图片示例](https://github.com/intsig-textin/textin-ocr-frontend/tree/main/src/examples/ImageExample.tsx)
340 |
341 | #### [PDF示例](https://github.com/intsig-textin/textin-ocr-frontend/tree/main/src/examples/PDFExample.tsx)
342 |
343 | ## 未来规划
344 |
345 | - 组件支持更多自定义配置、样式覆盖等特性
346 | - 支持可编辑、复制、导出结果
347 | - 支持更多复杂类型如通用文档解析识别结果展示
348 |
349 | ## 二次开发
350 |
351 | 项目基于 vite 和 react 构建,您可将该项目 fork 到本地自主扩展:
352 |
353 | 拉取项目
354 |
355 | ```bash
356 | git clone https://github.com/intsig-textin/textin-ocr-frontend.git
357 | ```
358 |
359 | 安装依赖
360 |
361 | ```bash
362 | npm install
363 | ```
364 |
365 | 启动项目
366 |
367 | ```bash
368 | npm run dev
369 | ```
370 |
371 | 浏览器访问 http://localhost:5173/
372 |
373 | ## 贡献
374 |
375 | 欢迎贡献代码!在开始之前,请阅读 [CONTRIBUTING.md](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/CONTRIBUTING.md) 以了解贡献流程和指南。
376 |
377 | ## 许可证
378 |
379 | 本项目采用 [CC-NC License](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/LICENSE)。
380 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | "@typescript-eslint/no-explicit-any": "warning"
27 | },
28 | },
29 | )
30 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | textin-ocr-frontend
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "textin-ocr-frontend",
3 | "version": "1.0.2",
4 | "private": false,
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "module": "dist/index.mjs",
8 | "types": "dist/index.d.ts",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/intsig-textin/textin-ocr-frontend.git"
12 | },
13 | "homepage": "https://github.com/intsig-textin/textin-ocr-frontend#readme",
14 | "bugs": {
15 | "url": "https://github.com/intsig-textin/textin-ocr-frontend/issues"
16 | },
17 | "files": [
18 | "dist",
19 | "README.md",
20 | "LICENSE"
21 | ],
22 | "scripts": {
23 | "dev": "vite",
24 | "build": "vite build",
25 | "lint": "eslint .",
26 | "preview": "vite preview"
27 | },
28 | "peerDependencies": {
29 | "react": ">=16.8.0",
30 | "react-dom": ">=16.8.0"
31 | },
32 | "dependencies": {
33 | "ahooks": "^3.8.4",
34 | "classnames": "^2.5.1",
35 | "react-json-view": "^1.21.3"
36 | },
37 | "devDependencies": {
38 | "@eslint/js": "^9.22.0",
39 | "@types/node": "^22.15.16",
40 | "@types/react": "^17.0.3",
41 | "@types/react-dom": "^17.0.3",
42 | "@vitejs/plugin-react": "^4.3.4",
43 | "eslint": "^9.22.0",
44 | "eslint-plugin-react-hooks": "^5.2.0",
45 | "eslint-plugin-react-refresh": "^0.4.19",
46 | "globals": "^16.0.0",
47 | "less": "^4.3.0",
48 | "typescript": "~5.7.2",
49 | "typescript-eslint": "^8.26.1",
50 | "vite": "^6.3.1",
51 | "vite-plugin-dts": "^4.5.3",
52 | "vite-plugin-svgr": "^4.3.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | background-color: white;
3 | color: #000;
4 | }
5 |
6 | .logo {
7 | height: 6em;
8 | padding: 1.5em;
9 | will-change: filter;
10 | transition: filter 300ms;
11 | }
12 | .logo:hover {
13 | filter: drop-shadow(0 0 2em #646cffaa);
14 | }
15 | .logo.react:hover {
16 | filter: drop-shadow(0 0 2em #61dafbaa);
17 | }
18 |
19 | @keyframes logo-spin {
20 | from {
21 | transform: rotate(0deg);
22 | }
23 | to {
24 | transform: rotate(360deg);
25 | }
26 | }
27 |
28 | @media (prefers-reduced-motion: no-preference) {
29 | a:nth-of-type(2) .logo {
30 | animation: logo-spin infinite 20s linear;
31 | }
32 | }
33 |
34 | .card {
35 | padding: 2em;
36 | }
37 |
38 | .read-the-docs {
39 | color: #888;
40 | }
41 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import "./App.css";
3 | import ImageExample from "./examples/ImageExample";
4 | import PDFExample from "./examples/PDFExample";
5 |
6 | const styles = {
7 | container: {
8 | display: "flex",
9 | width: "100vw",
10 | height: "100vh",
11 | overflow: "hidden",
12 | },
13 | sidebar: {
14 | width: "160px",
15 | backgroundColor: "#f5f5f5",
16 | borderRight: "1px solid #e0e0e0",
17 | padding: "16px",
18 | },
19 | sidebarTitle: {
20 | fontSize: "14px",
21 | color: "#666",
22 | marginBottom: "8px",
23 | padding: "8px 0",
24 | },
25 | menuItem: {
26 | padding: "8px 12px",
27 | cursor: "pointer",
28 | borderRadius: "4px",
29 | marginBottom: "4px",
30 | },
31 | menuItemActive: {
32 | color: "#1a66ff",
33 | },
34 | menuItemInactive: {
35 | color: "#333",
36 | backgroundColor: "transparent",
37 | },
38 | content: {
39 | flex: 1,
40 | padding: "16px",
41 | overflow: "auto",
42 | },
43 | } as const;
44 |
45 | function App() {
46 | const [activeExample, setActiveExample] = useState<"image" | "pdf">("image");
47 |
48 | const getMenuItemStyle = (isActive: boolean) => ({
49 | ...styles.menuItem,
50 | ...(isActive ? styles.menuItemActive : styles.menuItemInactive),
51 | });
52 |
53 | return (
54 |
55 | {/* 左侧菜单 */}
56 |
57 |
示例列表
58 |
setActiveExample("image")}
60 | style={getMenuItemStyle(activeExample === "image")}
61 | >
62 | 示例一:单张图片
63 |
64 |
setActiveExample("pdf")}
66 | style={getMenuItemStyle(activeExample === "pdf")}
67 | >
68 | 示例二:多页PDF
69 |
70 |
71 |
72 | {/* 右侧内容区 */}
73 |
74 | {activeExample === "image" ?
:
}
75 |
76 |
77 | );
78 | }
79 |
80 | export default App;
81 |
--------------------------------------------------------------------------------
/src/assets/icon_img_enlarge_default.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/icon_img_narrow_default.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/icon_img_normal_default.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/icon_img_rotate+90_default.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/icon_img_rotate-90_default.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/outline-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/outline-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/FilePreview/ImagesViewer/index.module.less:
--------------------------------------------------------------------------------
1 | .imagesViewer {
2 | width: 100%;
3 | height: 100%;
4 | }
5 |
6 | .imageWrapper {
7 | position: absolute;
8 | left: 0;
9 | right: 0;
10 | top: 0;
11 | bottom: 0;
12 | text-align: center;
13 | display: flex;
14 | justify-content: center;
15 | align-items: center;
16 | .image {
17 | max-width: 100%;
18 | max-height: 100%;
19 | vertical-align: middle;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/FilePreview/ImagesViewer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode, useRef } from "react";
2 | import classNames from "classnames";
3 | import MarkLayer from "../../MarkLayer";
4 | import useMarkTool from "../../../hooks/useMarkTool";
5 | import { IPageItem, IRectItem } from "../../../types/keyVal";
6 | import styles from "./index.module.less";
7 | import { BlockLoading } from "../../Loading";
8 |
9 | export interface IImagesViewerProps {
10 | wrapperClassName?: string;
11 | wrapperStyle?: React.CSSProperties;
12 | pageClassName?: string;
13 | pageStyle?: React.CSSProperties;
14 | srcList: string[];
15 | rects?: IRectItem[][];
16 | pages?: IPageItem[];
17 | showMark?: boolean;
18 | viewContainerRef?: React.RefObject;
19 | viewRef?: React.RefObject;
20 | activeContentId?: string;
21 | scale: number;
22 | rotate: number;
23 | position?: { x: number; y: number };
24 | loading?: boolean;
25 | onMouseDown?: (e: any) => void;
26 | resizeScale?: () => void;
27 | loadingComponent?: ReactNode;
28 | }
29 |
30 | export default function ImagesViewer({
31 | wrapperClassName,
32 | wrapperStyle,
33 | pageClassName,
34 | pageStyle,
35 | srcList,
36 | rects,
37 | pages,
38 | showMark,
39 | viewContainerRef,
40 | viewRef,
41 | activeContentId,
42 | scale,
43 | rotate,
44 | position,
45 | loading,
46 | loadingComponent,
47 | onMouseDown,
48 | resizeScale,
49 | }: IImagesViewerProps) {
50 | return (
51 | }
55 | >
56 | {srcList.map((src, index) => (
57 |
73 | ))}
74 | {loading && (loadingComponent || )}
75 |
76 | );
77 | }
78 |
79 | export interface IImageViewProps {
80 | className?: string;
81 | style?: React.CSSProperties;
82 | src: string;
83 | rects?: IRectItem[];
84 | page?: IPageItem;
85 | showMark?: boolean;
86 | viewContainerRef?: React.RefObject;
87 | activeContentId?: string;
88 | rotate: number;
89 | fixedRotate?: number;
90 | scale: number;
91 | onMouseDown?: (e: any) => void;
92 | resizeScale?: () => void;
93 | // 需要旋转角补位
94 | angleFix?: boolean;
95 | // 需要旋转的角度
96 | angle?: number;
97 | position?: { x: number; y: number };
98 | }
99 |
100 | export function ImageView({
101 | className,
102 | style,
103 | src,
104 | rects,
105 | showMark,
106 | viewContainerRef,
107 | activeContentId,
108 | rotate,
109 | scale,
110 | onMouseDown,
111 | resizeScale,
112 | angleFix,
113 | angle,
114 | position,
115 | }: IImageViewProps) {
116 | const imgRef = useRef(null);
117 | const { renderRate, markStyle, updateMark } = useMarkTool({
118 | imgRef,
119 | viewContainerRef,
120 | resizeScale,
121 | angleFix,
122 | angle,
123 | });
124 | const angleBasic = angleFix && angle ? rotate + angle : rotate;
125 |
126 | const handleImageLoad = () => {
127 | updateMark();
128 | };
129 |
130 | return (
131 |
138 |

146 | {showMark && rects && (
147 |
viewContainerRef?.current}
157 | />
158 | )}
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/src/components/FilePreview/PDFViewer/index.module.less:
--------------------------------------------------------------------------------
1 | @fill-alpha: 0;
2 | @fill-active-alpha: 0.15;
3 | @stroke-alpha: 1;
4 | @primary-color: #1a66ff;
5 |
6 | @paragraph-color: @primary-color;
7 | @title-color: @primary-color;
8 | @stamp-color: #ad581a;
9 | @list-color: #5d2281;
10 | @image-color: #bd8d1c;
11 | @formula-color: #d94141;
12 | @catalog-color: #695cff;
13 | @handwriting-color: #9c32d1;
14 | @question_stem-color: #9c32d1;
15 | @question_content-color: #0a91f2;
16 | @watermark-color: #6abe28;
17 | @table-color: #11a35f;
18 | @header-footer-color: #637599; // 页眉页脚
19 |
20 | .pdf-viewer {
21 | position: absolute;
22 | top: 0;
23 | right: 0;
24 | bottom: 0;
25 | left: 0;
26 | box-sizing: content-box;
27 | width: 100%;
28 | height: 100%;
29 | overflow: auto;
30 |
31 | &::-webkit-scrollbar {
32 | width: 8px;
33 | height: 8px;
34 | }
35 |
36 | &::-webkit-scrollbar-thumb {
37 | background-color: #a2a8b2;
38 | border-radius: 8px;
39 | }
40 |
41 | * {
42 | box-sizing: content-box;
43 |
44 | user-select: text;
45 |
46 | ::selection {
47 | color: transparent;
48 | }
49 | }
50 |
51 | :global {
52 | .pdfViewer.removePageBorders .page {
53 | margin-bottom: 12px;
54 | &:last-child {
55 | margin-bottom: 0;
56 | }
57 | }
58 | .page {
59 | .rectLayer {
60 | position: absolute;
61 | top: 0;
62 | right: 0;
63 | bottom: 0;
64 | left: 0;
65 | z-index: 10;
66 | overflow: visible;
67 | transform-origin: left top;
68 |
69 | polygon,
70 | polyline,
71 | path {
72 | vector-effect: non-scaling-stroke;
73 | }
74 |
75 | polygon {
76 | fill: rgba(@primary-color, @fill-alpha);
77 | stroke: rgba(@primary-color, 0.5);
78 | stroke-width: 1px;
79 |
80 | &.active {
81 | fill: rgba(@primary-color, @fill-active-alpha);
82 | stroke: @primary-color;
83 | stroke-width: 2px;
84 | }
85 |
86 | &.paragraph {
87 | fill: rgba(@paragraph-color, @fill-alpha);
88 | stroke: rgba(@paragraph-color, 0.6);
89 |
90 | &.active {
91 | fill: rgba(@paragraph-color, @fill-active-alpha);
92 | stroke: @paragraph-color;
93 | }
94 | }
95 |
96 | &.table {
97 | fill: rgba(@table-color, @fill-alpha);
98 | stroke: rgba(@table-color, @stroke-alpha);
99 |
100 | &.active {
101 | fill: rgba(@table-color, @fill-active-alpha);
102 | stroke: @table-color;
103 | }
104 | }
105 |
106 | &.stamp {
107 | fill: rgba(@stamp-color, @fill-alpha);
108 | stroke: rgba(@stamp-color, @stroke-alpha);
109 |
110 | &.active {
111 | fill: rgba(@stamp-color, @fill-active-alpha);
112 | stroke: @stamp-color;
113 | }
114 | }
115 |
116 | &.image {
117 | fill: rgba(@image-color, @fill-alpha);
118 | stroke: rgba(@image-color, @stroke-alpha);
119 |
120 | &.active {
121 | fill: rgba(@image-color, @fill-active-alpha);
122 | stroke: @image-color;
123 | }
124 | }
125 |
126 | &.formula {
127 | fill: rgba(@formula-color, @fill-alpha);
128 | stroke: rgba(@formula-color, @stroke-alpha);
129 |
130 | &.active {
131 | fill: rgba(@formula-color, @fill-active-alpha);
132 | stroke: @formula-color;
133 | }
134 | }
135 |
136 | &.handwriting {
137 | fill: rgba(@handwriting-color, @fill-alpha);
138 | stroke: rgba(@handwriting-color, @stroke-alpha);
139 |
140 | &.active {
141 | fill: rgba(@handwriting-color, @fill-active-alpha);
142 | stroke: @handwriting-color;
143 | }
144 | }
145 |
146 | &.catalog_text {
147 | fill: rgba(@catalog-color, @fill-alpha);
148 | stroke: rgba(@catalog-color, @stroke-alpha);
149 |
150 | &.active {
151 | fill: rgba(@catalog-color, @fill-active-alpha);
152 | stroke: @catalog-color;
153 | }
154 | }
155 |
156 | &.title {
157 | fill: rgba(@title-color, @fill-alpha);
158 | stroke: rgba(@title-color, 1);
159 | stroke-width: 2px;
160 |
161 | &.active {
162 | fill: rgba(@title-color, @fill-active-alpha);
163 | stroke: @title-color;
164 | }
165 | }
166 |
167 | &.question_stem {
168 | fill: rgba(@question_stem-color, @fill-alpha);
169 | stroke: rgba(@question_stem-color, @stroke-alpha);
170 |
171 | &.active {
172 | fill: rgba(@question_stem-color, @fill-active-alpha);
173 | stroke: @question_stem-color;
174 | }
175 | }
176 |
177 | &.question_content {
178 | fill: rgba(@question_content-color, @fill-alpha);
179 | stroke: rgba(@question_content-color, @stroke-alpha);
180 |
181 | &.active {
182 | fill: rgba(@question_content-color, @fill-active-alpha);
183 | stroke: @question_content-color;
184 | }
185 | }
186 |
187 | &.header_footer {
188 | fill: rgba(@header-footer-color, 0.1);
189 | stroke: rgba(@header-footer-color, @stroke-alpha);
190 |
191 | &.active {
192 | fill: rgba(@header-footer-color, @fill-active-alpha);
193 | stroke: @header-footer-color;
194 | }
195 | }
196 |
197 | &.catalog {
198 | // box-shadow: 2px 2px 4px 0px #e6fffb, -2px -2px 4px 0px #ffffff;
199 | opacity: 0;
200 | // fill: rgba(#00474f, @fill-alpha);
201 | // stroke: #00474f;
202 |
203 | &.active:local {
204 | transform-origin: center;
205 | animation: catalogAnimate 0.5s linear;
206 | transform-box: fill-box;
207 |
208 | @keyframes catalogAnimate {
209 | 0% {
210 | transform: scale(1.08);
211 | }
212 |
213 | 25% {
214 | transform: scale(0.95);
215 | }
216 |
217 | 50% {
218 | transform: scale(1.08);
219 | }
220 |
221 | 75% {
222 | transform: scale(0.95);
223 | }
224 |
225 | 100% {
226 | transform: scale(1);
227 | }
228 | }
229 | }
230 | }
231 | }
232 |
233 | .cell-g-wrapper {
234 | &.cell-g-hidden {
235 | polygon.table {
236 | opacity: 1 !important;
237 | fill: rgba(@table-color, @fill-alpha);
238 | &.active {
239 | fill: rgba(@table-color, @fill-active-alpha);
240 | }
241 | }
242 | path {
243 | opacity: 0;
244 | }
245 | .cell-toggle-show {
246 | display: none;
247 | }
248 | .cell-toggle-hidden {
249 | display: block;
250 | }
251 | }
252 |
253 | &.cell-g-wrapper-hover {
254 | .cell-toggle-hidden,
255 | .cell-toggle-show {
256 | opacity: 1;
257 | }
258 | }
259 |
260 | path {
261 | fill: rgba(@paragraph-color, @fill-alpha);
262 | stroke: rgba(@paragraph-color, @stroke-alpha);
263 | stroke-width: 1px;
264 |
265 | &.table {
266 | fill: rgba(@table-color, @fill-alpha);
267 | stroke: rgba(@table-color, @stroke-alpha);
268 |
269 | &.active {
270 | fill: rgba(@table-color, @fill-active-alpha);
271 | stroke-width: 2px;
272 | }
273 | }
274 | }
275 |
276 | polygon.table {
277 | fill: transparent;
278 |
279 | &:not(:only-child) {
280 | opacity: 0;
281 | }
282 | }
283 |
284 | .cell-toggle-hidden {
285 | display: none;
286 | opacity: 0;
287 | }
288 | .cell-toggle-show {
289 | display: block;
290 | opacity: 0;
291 | }
292 | }
293 |
294 | .section-line {
295 | circle {
296 | fill: rgba(@primary-color, 1);
297 | }
298 | polyline {
299 | fill: none;
300 | stroke: rgba(@primary-color, 1);
301 | stroke-dasharray: 4;
302 | stroke-width: 2px;
303 | }
304 | path {
305 | fill: rgba(@primary-color, 1);
306 | }
307 |
308 | &.section-table {
309 | circle {
310 | fill: rgba(@table-color, 1);
311 | }
312 | polyline {
313 | stroke: rgba(@table-color, 1);
314 | }
315 | path {
316 | fill: rgba(@table-color, 1);
317 | }
318 | }
319 | }
320 | }
321 | .textLayer {
322 | z-index: 9;
323 | }
324 | }
325 | }
326 | }
327 |
328 | .pdf-page {
329 | position: absolute;
330 | right: 8px;
331 | bottom: -24px - 18px;
332 | z-index: 99;
333 | width: auto;
334 | }
335 |
--------------------------------------------------------------------------------
/src/components/FilePreview/PDFViewer/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useEffect, useRef, useState } from "react";
2 | import { useDebounceFn, useExternal, useSize } from "ahooks";
3 | import classNames from "classnames";
4 | import { useLoadPDFLib } from "../../../hooks/useLoadPDFLib";
5 | import { IPageItem, IRectItem } from "../../../types/keyVal";
6 | import { usePDFMarkLayer } from "../../../hooks/usePDFMarkLayer";
7 | import { isBoolean } from "../../../utils/object";
8 | import { BlockLoading } from "../../Loading";
9 | import Pagination from "../Pagination";
10 | import { PDFSrc } from "./types";
11 | import styles from "./index.module.less";
12 |
13 | const defaultPDFScaleValue = "page-width";
14 |
15 | export interface IPDFViewerProps {
16 | src: PDFSrc;
17 | password?: string;
18 | rects?: IRectItem[][];
19 | pages: IPageItem[];
20 | showMark?: boolean;
21 | activeContentId?: string;
22 | dpi?: number;
23 | scale?: number;
24 | rotate?: number;
25 | loading?: boolean;
26 | loadingComponent?: ReactNode;
27 | onLoad?: (e: any) => void;
28 | onError?: (e: any) => void;
29 | }
30 |
31 | export default function PDFViewer({
32 | src,
33 | rects,
34 | pages,
35 | showMark = true,
36 | activeContentId,
37 | dpi,
38 | scale,
39 | rotate,
40 | loading: propLoading,
41 | loadingComponent,
42 | onLoad,
43 | onError,
44 | }: IPDFViewerProps) {
45 | const { pdfLibReady, buildDir, cmapsURL } = useLoadPDFLib({
46 | onError: () => onError?.({}),
47 | });
48 | const [loading, setLoading] = useState(true);
49 | const [currentPage, setCurrentPage] = useState(1);
50 | const [totalPage, setTotalPage] = useState(1);
51 |
52 | const containerRef = useRef();
53 | const onContainerRef = (ref: any) => {
54 | containerRef.current = ref;
55 | };
56 | const viewerRef = useRef();
57 | const timeoutRef = useRef();
58 |
59 | const viewerCss = useExternal(`${buildDir}/web/pdf_viewer.css`);
60 | const viewer = useExternal(
61 | pdfLibReady ? `${buildDir}/web/pdf_viewer.js` : undefined
62 | );
63 | const sandbox = useExternal(
64 | pdfLibReady ? `${buildDir}/build/pdf.sandbox.js` : undefined
65 | );
66 |
67 | const resize = () => {
68 | if (viewerRef.current) {
69 | viewerRef.current.currentScaleValue = defaultPDFScaleValue;
70 | }
71 | };
72 |
73 | const { run: debouncedResize } = useDebounceFn(resize, { wait: 300 });
74 |
75 | const viewContainerSize = useSize(containerRef.current?.parentElement);
76 |
77 | const { run: createMark } = usePDFMarkLayer({
78 | containerRef: containerRef,
79 | pdfViewerRef: viewerRef,
80 | rects,
81 | pages,
82 | showMark,
83 | activeContentId,
84 | dpi,
85 | });
86 |
87 | useEffect(() => {
88 | debouncedResize();
89 | }, [viewContainerSize?.width]);
90 |
91 | useEffect(() => {
92 | if ([viewerCss, viewer, sandbox].includes("error")) {
93 | onError?.({});
94 | const info = {
95 | name: "pdf.js错误",
96 | keyword: "pdf_viewer加载失败",
97 | message: {
98 | "pdf_viewer.css": viewerCss,
99 | "pdf_viewer.js": viewer,
100 | "pdf.sandbox.js": sandbox,
101 | },
102 | };
103 | console.log(info);
104 | }
105 | }, [viewerCss, viewer, sandbox]);
106 |
107 | useEffect(() => {
108 | if (
109 | window.pdfjsSandbox &&
110 | viewerCss === "ready" &&
111 | window.pdfjsViewer &&
112 | pdfLibReady
113 | ) {
114 | clearTimeout(timeoutRef.current);
115 | onLoad?.({});
116 | setCurrentPage(1);
117 | setTotalPage(0);
118 | if (viewerRef.current?.setDocument) {
119 | viewerRef.current.setDocument(null);
120 | }
121 | setLoading(true);
122 | handleInit()
123 | .catch((error) => {
124 | if (onError) {
125 | onError?.({});
126 | } else {
127 | console.log("pdf预览失败", error);
128 | }
129 | const info = {
130 | name: "pdf.js错误",
131 | keyword: "pdf_viewer渲染出错",
132 | message: error,
133 | };
134 | console.log(info);
135 | })
136 | .finally(() => {
137 | setLoading(false);
138 | });
139 | }
140 | }, [viewerCss, viewer, sandbox, pdfLibReady]);
141 |
142 | const handleInit = async () => {
143 | if (!src) {
144 | return;
145 | }
146 | const container = containerRef.current;
147 | const eventBus = new window.pdfjsViewer.EventBus();
148 | /**
149 | * options
150 | * https://github.com/mozilla/pdf.js/blob/v2.11.338/web/base_viewer.js#L188-L204
151 | */
152 | const pdfViewer = new window.pdfjsViewer.PDFViewer({
153 | container,
154 | eventBus,
155 | // annotationMode: 0, // 禁用注释
156 | removePageBorders: true, // 移除页边框
157 | });
158 |
159 | eventBus.on("pagesinit", function () {
160 | pdfViewer.currentScaleValue = defaultPDFScaleValue;
161 | });
162 | /**
163 | * options
164 | * https://github.com/mozilla/pdf.js/blob/v2.11.338/src/display/api.js#L320-L328
165 | */
166 | const loadingTask = window.pdfjsLib.getDocument({
167 | cmapsURL,
168 | cMapPacked: true,
169 | ...src,
170 | });
171 | const pdfDocument = await loadingTask.promise;
172 | pdfViewer.setDocument(pdfDocument);
173 |
174 | viewerRef.current = pdfViewer;
175 |
176 | createMark();
177 |
178 | setTotalPage(viewerRef.current.pdfDocument.numPages);
179 | viewerRef.current.eventBus.on("pagechanging", () => {
180 | setCurrentPage(viewerRef.current.currentPageNumber);
181 | });
182 | };
183 |
184 | const setScaleAndRotate = ({
185 | scale,
186 | rotate,
187 | }: {
188 | scale?: number;
189 | rotate?: number;
190 | }) => {
191 | if (!viewerRef.current) {
192 | return;
193 | }
194 | viewerRef.current.currentScale = scale || 1;
195 | if (viewerRef.current.currentScale === 1) {
196 | viewerRef.current.currentScaleValue = defaultPDFScaleValue;
197 | }
198 | viewerRef.current.pagesRotation = rotate || 0;
199 | };
200 |
201 | useEffect(() => {
202 | setScaleAndRotate({ scale, rotate });
203 | }, [scale, rotate]);
204 |
205 | const onPageChange = (page: number) => {
206 | viewerRef.current.currentPageNumber = page;
207 | setCurrentPage(page);
208 | };
209 |
210 | const pdfLoading = isBoolean(propLoading) ? propLoading : loading;
211 |
212 | return (
213 |
214 |
219 |
220 | {pdfLoading && (loadingComponent ||
)}
221 |
222 |
228 |
229 | );
230 | }
231 |
--------------------------------------------------------------------------------
/src/components/FilePreview/PDFViewer/types.ts:
--------------------------------------------------------------------------------
1 | import { TypedArray } from "../../../types/common";
2 |
3 | export interface DocumentInitParameters {
4 | [key: string]: any;
5 | url?: string | URL;
6 | data?: TypedArray | ArrayBuffer | Array | string;
7 | httpHeaders?: Object;
8 | withCredentials?: boolean;
9 | password?: string;
10 | length?: boolean;
11 | }
12 |
13 | export type PDFSrc = DocumentInitParameters;
14 |
--------------------------------------------------------------------------------
/src/components/FilePreview/Pagination/index.module.less:
--------------------------------------------------------------------------------
1 | .pagination {
2 | display: inline-flex;
3 | align-items: center;
4 | gap: 8px;
5 | font-size: 14px;
6 |
7 | .pageChangeIcon {
8 | width: 24px;
9 | height: 24px;
10 | cursor: pointer;
11 |
12 | &.disabled {
13 | g {
14 | fill: #c5c7cf;
15 | }
16 | cursor: not-allowed;
17 | }
18 | }
19 |
20 | .pageIndicator {
21 | height: 24px;
22 | .pageInput {
23 | color: inherit;
24 | box-sizing: border-box;
25 | height: 100%;
26 | margin-right: 8px;
27 | padding: 0 6px;
28 | text-align: center;
29 | background-color: #fff;
30 | border: 1px solid #dcdfe5;
31 | border-radius: 2px;
32 | outline: none;
33 | transition: border-color 0.3s;
34 |
35 | &:disabled {
36 | background-color: #f5f5f5;
37 | cursor: not-allowed;
38 | }
39 |
40 | &:focus {
41 | border-color: #1a66ff;
42 | box-shadow: 0 0 0 2px rgba(26, 102, 255, 0.2);
43 | }
44 | }
45 |
46 | .separator {
47 | margin: 0 10px 0 5px;
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/FilePreview/Pagination/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import classNames from "classnames";
3 | import OutlineLeft from "../../../assets/outline-left.svg?react";
4 | import OutlineRight from "../../../assets/outline-right.svg?react";
5 | import styles from "./index.module.less";
6 |
7 | export interface IPaginationProps {
8 | className?: string;
9 | style?: React.CSSProperties;
10 | current: number;
11 | total: number;
12 | onChange?: (page: number) => void;
13 | }
14 |
15 | export default function Pagination({
16 | className,
17 | style,
18 | current,
19 | total,
20 | onChange,
21 | }: IPaginationProps) {
22 | const [inputPage, setInputPage] = useState(current.toString());
23 |
24 | useEffect(() => {
25 | if (current) {
26 | setInputPage(current.toString());
27 | }
28 | }, [current]);
29 |
30 | const handleChange = (page: number) => {
31 | if (page < 1 || page > total || page === current) return;
32 | onChange?.(page);
33 | };
34 |
35 | const handleJump = (_e: React.KeyboardEvent | React.FocusEvent) => {
36 | const page = parseInt(inputPage);
37 | if (isNaN(page) || page < 1 || page > total) {
38 | return;
39 | }
40 | handleChange(page);
41 | };
42 |
43 | if (total === 0) return null;
44 |
45 | return (
46 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/FilePreview/Toolbar/index.module.less:
--------------------------------------------------------------------------------
1 | .toolbar {
2 | position: relative;
3 | .toolbarList {
4 | display: flex;
5 | align-items: center;
6 | justify-content: center;
7 | width: 100%;
8 | min-height: 60px;
9 | padding: 20px 0;
10 | color: #030a1a;
11 | text-align: center;
12 | list-style: none;
13 | pointer-events: auto;
14 | box-sizing: border-box;
15 | margin: 0;
16 | padding: 0;
17 | .toolItem {
18 | height: 20px;
19 | margin-left: 16px;
20 | line-height: 20px;
21 | cursor: pointer;
22 | }
23 | .toolItemIcon {
24 | width: 20px;
25 | height: 20px;
26 | }
27 | .toolItemDisabled {
28 | pointer-events: none;
29 | color: #c5c7cf;
30 | :global {
31 | svg g {
32 | fill: #c5c7cf;
33 | }
34 | }
35 | }
36 | .toolSplitLine {
37 | width: 1px;
38 | height: 20px;
39 | margin-left: 16px;
40 | overflow: hidden;
41 | font-size: 20px;
42 | line-height: 20px;
43 | background: #c5c7cf;
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/FilePreview/Toolbar/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { PreviewToolItem } from "../../../hooks/usePreviewTool";
3 | import styles from "./index.module.less";
4 |
5 | export interface IToolbarProps {
6 | className?: string;
7 | style?: React.CSSProperties;
8 | tools: PreviewToolItem[];
9 | }
10 |
11 | export default function Toolbar({ className, style, tools }: IToolbarProps) {
12 | return (
13 |
14 |
15 | {tools.map(({ Icon, onClick, type, disabled, ...props }) => {
16 | return (
17 | -
26 | {type !== "line" && Icon && (
27 |
28 | )}
29 |
30 | );
31 | })}
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/FilePreview/index.module.less:
--------------------------------------------------------------------------------
1 | .filePreview {
2 | max-height: 100%;
3 | max-width: 100%;
4 | width: 100%;
5 | height: 100%;
6 | overflow: hidden;
7 | .filePreviewWrapper {
8 | position: relative;
9 | width: 100%;
10 | height: calc(100% - 60px);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/FilePreview/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useRef } from "react";
2 | import classNames from "classnames";
3 | import { ToolbarOptions, usePreviewTool } from "../../hooks/usePreviewTool";
4 | import { IPageItem, IRectItem } from "../../types/keyVal";
5 | import { isBoolean } from "../../utils/object";
6 | import ImagesViewer from "./ImagesViewer";
7 | import PDFViewer from "./PDFViewer";
8 | import { PDFSrc } from "./PDFViewer/types";
9 | import Toolbar from "./Toolbar";
10 | import styles from "./index.module.less";
11 |
12 | export const filePreviewContainerId = "filePreviewContainer";
13 |
14 | export interface IFilePreviewProps {
15 | className?: string;
16 | style?: React.CSSProperties;
17 | src: PDFSrc | string[];
18 | rects?: IRectItem[][];
19 | pages: IPageItem[];
20 | getContainerRef: React.RefObject;
21 | activeContentId?: string;
22 | showMark?: boolean;
23 | hasPagination?: boolean;
24 | hasToolbar?: boolean;
25 | toolbarOptions?: ToolbarOptions;
26 | toolbarStyle?: React.CSSProperties;
27 | loading?: boolean;
28 | loadingComponent?: ReactNode;
29 | }
30 |
31 | export default function FilePreview({
32 | className,
33 | style,
34 | src,
35 | rects,
36 | pages,
37 | getContainerRef,
38 | activeContentId,
39 | showMark = true,
40 | hasPagination,
41 | hasToolbar = true,
42 | toolbarOptions,
43 | toolbarStyle,
44 | loading,
45 | loadingComponent,
46 | }: IFilePreviewProps) {
47 | const viewContainerRef = getContainerRef;
48 | const viewRef = useRef(null);
49 | const { tools, scale, position, rotate, onMouseDown, resizeScale } =
50 | usePreviewTool({ viewContainerRef, viewRef, toolbarOptions });
51 |
52 | const isPdf = !Array.isArray(src);
53 | const needPagination = isBoolean(hasPagination) ? hasPagination : isPdf;
54 |
55 | return (
56 | }
58 | className={classNames(styles.filePreview, className)}
59 | style={style}
60 | >
61 |
65 | {Array.isArray(src) ? (
66 |
82 | ) : (
83 |
94 | )}
95 |
96 | {hasToolbar && (
97 |
104 | )}
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/FilePreview/types.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/intsig-textin/textin-ocr-frontend/7e08cd54bea12eac5c952a63085f0669a9feef9a/src/components/FilePreview/types.ts
--------------------------------------------------------------------------------
/src/components/JsonView/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Json结果展示组件
3 | * TODO: 接口和使用说明
4 | */
5 |
6 | import ReactJson, { ReactJsonViewProps } from "react-json-view";
7 |
8 | export default function JsonView({ style, src, ...rest }: ReactJsonViewProps) {
9 | return (
10 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/Loading/index.module.less:
--------------------------------------------------------------------------------
1 | .loading-spinner {
2 | border-radius: 50%;
3 | box-sizing: border-box;
4 | animation: spin infinite 0.75s linear;
5 | }
6 |
7 | @keyframes spin {
8 | 0% {
9 | transform: rotate(0deg);
10 | }
11 | 100% {
12 | transform: rotate(360deg);
13 | }
14 | }
15 |
16 | .block-loading {
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | width: 100%;
21 | height: 100%;
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | background: #f0f2f5;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import classNames from "classnames";
3 | import styles from "./index.module.less";
4 |
5 | interface LoadingProps {
6 | size?: number;
7 | color?: string;
8 | className?: string;
9 | }
10 |
11 | const Loading: React.FC = ({
12 | className,
13 | size = 20,
14 | color = "#1a66ff",
15 | }) => {
16 | const spinnerStyle = {
17 | width: `${size}px`,
18 | height: `${size}px`,
19 | border: `2px solid ${color}`,
20 | borderTopColor: "transparent",
21 | };
22 |
23 | return (
24 |
28 | );
29 | };
30 |
31 | export default Loading;
32 |
33 | export function BlockLoading(props: LoadingProps) {
34 | return (
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/SvgRect/Text.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from "react";
2 |
3 | const textStyle = {
4 | fontSize: 12,
5 | fill: "#ffffff",
6 | };
7 |
8 | interface ITextProps {
9 | points: number[];
10 | num: number;
11 | }
12 |
13 | export default memo(({ points, num }) => {
14 | return (
15 | <>
16 |
17 |
24 | {num}
25 |
26 | >
27 | );
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/SvgRect/index.module.less:
--------------------------------------------------------------------------------
1 | @fill-alpha: 0;
2 | @fill-active-alpha: 0.15;
3 | @stroke-alpha: 1;
4 | @primary-color: #1a66ff;
5 |
6 | @paragraph-color: @primary-color;
7 | @title-color: @primary-color;
8 | @stamp-color: #ad581a;
9 | @list-color: #5d2281;
10 | @image-color: #bd8d1c;
11 | @formula-color: #d94141;
12 | @catalog-color: #695cff;
13 | @handwriting-color: #9c32d1;
14 | @question_stem-color: #9c32d1;
15 | @question_content-color: #0a91f2;
16 | @watermark-color: #6abe28;
17 | @table-color: #11a35f;
18 | @header-footer-color: #637599; // 页眉页脚
19 |
20 | .svg {
21 | position: absolute;
22 | top: 0;
23 | left: 0;
24 | width: 100%;
25 | height: 100%;
26 | overflow: visible !important;
27 | cursor: default;
28 |
29 | &.over-range {
30 | display: none;
31 | }
32 |
33 | polygon,
34 | polyline,
35 | path {
36 | vector-effect: non-scaling-stroke;
37 | }
38 |
39 | polygon {
40 | position: relative;
41 | fill: rgba(@primary-color, @fill-alpha);
42 | stroke: rgba(@primary-color, 0.5);
43 | stroke-width: 1px;
44 | }
45 |
46 | :global {
47 | .active {
48 | fill: rgba(@primary-color, @fill-active-alpha);
49 | stroke: @primary-color;
50 | stroke-width: 2px;
51 | }
52 |
53 | .paragraph {
54 | fill: rgba(@paragraph-color, @fill-alpha);
55 | stroke: rgba(@paragraph-color, 0.6);
56 |
57 | &.active {
58 | fill: rgba(@paragraph-color, @fill-active-alpha);
59 | stroke: @paragraph-color;
60 | }
61 | }
62 |
63 | .table,
64 | .editable {
65 | fill: rgba(@table-color, @fill-alpha);
66 | stroke: rgba(@table-color, @stroke-alpha);
67 |
68 | &.active {
69 | fill: rgba(@table-color, @fill-active-alpha);
70 | stroke: @table-color;
71 | }
72 | }
73 |
74 | .stamp {
75 | fill: rgba(@stamp-color, @fill-alpha);
76 | stroke: rgba(@stamp-color, @stroke-alpha);
77 |
78 | &.active {
79 | fill: rgba(@stamp-color, @fill-active-alpha);
80 | stroke: @stamp-color;
81 | }
82 | }
83 |
84 | .image {
85 | fill: rgba(@image-color, @fill-alpha);
86 | stroke: rgba(@image-color, @stroke-alpha);
87 |
88 | &.active {
89 | fill: rgba(@image-color, @fill-active-alpha);
90 | stroke: @image-color;
91 | }
92 | }
93 |
94 | .formula {
95 | fill: rgba(@formula-color, @fill-alpha);
96 | stroke: rgba(@formula-color, @stroke-alpha);
97 |
98 | &.active {
99 | fill: rgba(@formula-color, @fill-active-alpha);
100 | stroke: @formula-color;
101 | }
102 | }
103 |
104 | .handwriting {
105 | fill: rgba(@handwriting-color, @fill-alpha);
106 | stroke: rgba(@handwriting-color, @stroke-alpha);
107 |
108 | &.active {
109 | fill: rgba(@handwriting-color, @fill-active-alpha);
110 | stroke: @handwriting-color;
111 | }
112 | }
113 |
114 | .catalog_text {
115 | fill: rgba(@catalog-color, @fill-alpha);
116 | stroke: rgba(@catalog-color, @stroke-alpha);
117 |
118 | &.active {
119 | fill: rgba(@catalog-color, @fill-active-alpha);
120 | stroke: @catalog-color;
121 | }
122 | }
123 |
124 | .title {
125 | fill: rgba(@title-color, @fill-alpha);
126 | stroke: rgba(@title-color, 1);
127 | stroke-width: 2px;
128 |
129 | &.active {
130 | fill: rgba(@title-color, @fill-active-alpha);
131 | stroke: @title-color;
132 | }
133 | }
134 |
135 | .question_stem {
136 | fill: rgba(@question_stem-color, @fill-alpha);
137 | stroke: rgba(@question_stem-color, @stroke-alpha);
138 |
139 | &.active {
140 | fill: rgba(@question_stem-color, @fill-active-alpha);
141 | stroke: @question_stem-color;
142 | }
143 | }
144 |
145 | .question_content {
146 | fill: rgba(@question_content-color, @fill-alpha);
147 | stroke: rgba(@question_content-color, @stroke-alpha);
148 |
149 | &.active {
150 | fill: rgba(@question_content-color, @fill-active-alpha);
151 | stroke: @question_content-color;
152 | }
153 | }
154 |
155 | .header_footer,
156 | .edge {
157 | fill: rgba(@header-footer-color, 0.1);
158 | stroke: rgba(@header-footer-color, @stroke-alpha);
159 |
160 | &.active {
161 | fill: rgba(@header-footer-color, @fill-active-alpha);
162 | stroke: @header-footer-color;
163 | }
164 | }
165 |
166 | .watermark {
167 | fill: rgba(@watermark-color, @fill-alpha);
168 | stroke: rgba(@watermark-color, @stroke-alpha);
169 |
170 | &.active {
171 | fill: rgba(@watermark-color, @fill-active-alpha);
172 | stroke: @watermark-color;
173 | }
174 | }
175 |
176 | .list {
177 | fill: rgba(@list-color, @fill-alpha);
178 | stroke: rgba(@list-color, @stroke-alpha);
179 |
180 | &.active {
181 | fill: rgba(@list-color, @fill-active-alpha);
182 | stroke: @list-color;
183 | }
184 | }
185 |
186 | .catalog {
187 | // box-shadow: 2px 2px 4px 0px #e6fffb, -2px -2px 4px 0px #ffffff;
188 | visibility: hidden;
189 | // fill: rgba(#00474f, 0.05);
190 | // stroke: #00474f;
191 |
192 | &.active:local {
193 | transform-origin: center;
194 | visibility: visible;
195 | animation: catalogAnimate 0.5s linear;
196 | transform-box: fill-box;
197 |
198 | @keyframes catalogAnimate {
199 | 0% {
200 | transform: scale(1.08);
201 | }
202 | 25% {
203 | transform: scale(0.95);
204 | }
205 | 50% {
206 | transform: scale(1.08);
207 | }
208 | 75% {
209 | transform: scale(0.95);
210 | }
211 | 100% {
212 | transform: scale(1);
213 | }
214 | }
215 | }
216 | }
217 |
218 | .cell-g-wrapper {
219 | &.cell-g-hidden {
220 | polygon.table {
221 | opacity: 1 !important;
222 | fill: rgba(@table-color, @fill-alpha);
223 | &.active {
224 | fill: rgba(@table-color, @fill-active-alpha);
225 | }
226 | }
227 | path {
228 | opacity: 0;
229 | }
230 | .cell-toggle-show {
231 | display: none;
232 | }
233 | .cell-toggle-hidden {
234 | display: block;
235 | }
236 | }
237 |
238 | &:hover {
239 | .cell-toggle-hidden,
240 | .cell-toggle-show {
241 | opacity: 1;
242 | }
243 | }
244 |
245 | path {
246 | fill: rgba(@paragraph-color, @fill-alpha);
247 | stroke: rgba(@paragraph-color, @stroke-alpha);
248 | stroke-width: 1px;
249 |
250 | &.table {
251 | fill: rgba(@table-color, @fill-alpha);
252 | stroke: rgba(@table-color, @stroke-alpha);
253 |
254 | &.active {
255 | fill: rgba(@table-color, @fill-active-alpha);
256 | stroke-width: 2px;
257 | }
258 | }
259 | }
260 |
261 | polygon.table {
262 | fill: transparent;
263 |
264 | &:not(:only-child) {
265 | opacity: 0;
266 | }
267 | }
268 |
269 | .cell-toggle-hidden {
270 | display: none;
271 | opacity: 0;
272 | }
273 | .cell-toggle-show {
274 | display: block;
275 | opacity: 0;
276 | }
277 | }
278 |
279 | .section-line {
280 | circle {
281 | fill: rgba(@primary-color, 1);
282 | }
283 | polyline {
284 | fill: none;
285 | stroke: rgba(@primary-color, 1);
286 | stroke-dasharray: 4;
287 | stroke-width: 2px;
288 | }
289 | path {
290 | fill: rgba(@primary-color, 1);
291 | }
292 |
293 | &.section-table {
294 | circle {
295 | fill: rgba(@table-color, 1);
296 | }
297 | polyline {
298 | stroke: rgba(@table-color, 1);
299 | }
300 | path {
301 | fill: rgba(@table-color, 1);
302 | }
303 | }
304 | }
305 | }
306 | }
307 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/SvgRect/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode, SVGProps } from "react";
2 | import { useState, memo, useEffect, useRef, useLayoutEffect } from "react";
3 | import { useSize } from "ahooks";
4 | import classNames from "classnames";
5 | import { scrollIntoViewIfNeeded } from "../../../utils/dom";
6 | import { IRectItem } from "../../../types/keyVal";
7 | import RectText from "./Text";
8 | import styles from "./index.module.less";
9 |
10 | interface ISvgRectProps {
11 | svgAttr?: SVGProps;
12 | rate: number;
13 | rectList: IRectItem[];
14 | showText?: boolean;
15 | autoLink?: boolean;
16 | className?: string;
17 | pageNumber?: number | string;
18 | hiddenOverRange?: boolean;
19 | viewAngle?: number;
20 | activeContentId?: string;
21 | getContainer: () => HTMLElement | null | undefined;
22 | onClick?: (rect: IRectItem) => void;
23 | }
24 |
25 | export default function SvgRect({
26 | className,
27 | svgAttr,
28 | rate,
29 | rectList,
30 | showText,
31 | pageNumber = "1",
32 | hiddenOverRange = true,
33 | viewAngle,
34 | activeContentId,
35 | getContainer,
36 | onClick,
37 | }: ISvgRectProps) {
38 | const [isOverRange, setIsOverRange] = useState(false);
39 | const [viewBox, setViewBox] = useState();
40 | const svgRef = useRef(null);
41 |
42 | const wrapperSize = useSize(svgRef as unknown as HTMLElement);
43 |
44 | useEffect(() => {
45 | // 隐藏超出范围的框
46 | try {
47 | if (
48 | svgRef.current &&
49 | svgRef.current.parentElement &&
50 | Array.isArray(rectList) &&
51 | rate
52 | ) {
53 | let { clientWidth, clientHeight } = svgRef.current.parentElement;
54 | const viewBox = svgRef.current.getAttribute("viewBox") || "";
55 | let viewRate = 1;
56 | if (viewBox) {
57 | const [_, __, width, height] = viewBox
58 | .split(" ")
59 | .map((i) => Number(i));
60 | viewRate = width / clientWidth;
61 | clientWidth = width;
62 | clientHeight = height;
63 | }
64 | setViewBox({ width: clientWidth, height: clientHeight, viewRate });
65 | const isOver = rectList.some(
66 | ({ position }) =>
67 | position[2] * rate - clientWidth > 5 ||
68 | position[4] * rate - clientWidth > 5 ||
69 | position[5] * rate - clientHeight > 5 ||
70 | position[7] * rate - clientHeight > 5
71 | );
72 | setIsOverRange(isOver);
73 | }
74 | } catch (error) {
75 | console.log("判断isOver", error);
76 | }
77 | }, [rectList, rate, wrapperSize, svgAttr?.viewBox]);
78 |
79 | return (
80 |
106 | );
107 | }
108 |
109 | interface IRectProps extends IRectItem {
110 | rate: number;
111 | active?: boolean;
112 | onClick?: (rect: IRectItem) => void;
113 | renderText: (point: number[]) => ReactNode;
114 | getContainer: () => HTMLElement | null | undefined;
115 | }
116 |
117 | const Rect = memo((rect: IRectProps) => {
118 | const {
119 | position,
120 | rate,
121 | active,
122 | parent_uid,
123 | uid,
124 | onClick,
125 | renderText,
126 | getContainer,
127 | } = rect;
128 | if (!rate || !(Array.isArray(position) && position.length)) return null;
129 |
130 | const rectRef = useRef(null);
131 |
132 | useLayoutEffect(() => {
133 | if (active && rectRef.current) {
134 | const container = getContainer();
135 | const scrollOptions: ScrollIntoViewOptions = {
136 | block: "nearest",
137 | inline: "nearest",
138 | };
139 | if (container) {
140 | scrollIntoViewIfNeeded(
141 | rectRef.current,
142 | container,
143 | scrollOptions,
144 | rectRef.current.getBoundingClientRect().height
145 | );
146 | } else {
147 | rectRef.current.scrollIntoView(scrollOptions);
148 | }
149 | }
150 | }, [active]);
151 |
152 | const list = position.map((val) => Math.round(val * rate));
153 | const finalPoints = `${list[0]} ${list[1]},${list[2]} ${list[3]},${list[4]} ${list[5]},${list[6]} ${list[7]}`;
154 | const rect_type = rect.rect_type || rect.type || "";
155 |
156 | return (
157 | <>
158 | onClick?.(rect)}
169 | />
170 | {renderText(list)}
171 | >
172 | );
173 | });
174 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/helpers.ts:
--------------------------------------------------------------------------------
1 | export function getImgWidth(result: any, img: any) {
2 | if (result?.width) {
3 | return result?.width;
4 | } else if (result?.rotated_image_width) {
5 | if (!img) return result.rotated_image_width;
6 | // 卡证类,不同服务的之间image_angle/rotated_image_width/rotated_image_height的逻辑不统一,只能通过尽量兼容
7 | const { rotated_image_width: width, rotated_image_height: height } = result;
8 | const { naturalWidth, naturalHeight } = img;
9 | // 图片是否旋转
10 | if (Math.abs(width / height - naturalWidth / naturalHeight) <= 0.02) {
11 | // 宽高比例一致,未旋转
12 | return width;
13 | }
14 | return height;
15 | }
16 | if (!img) return 1;
17 | const { width, height, naturalWidth, naturalHeight } = img;
18 | // 图片是否旋转
19 | if (Math.abs(width / height - naturalWidth / naturalHeight) <= 0.02) {
20 | // 宽高比例一致,未旋转
21 | return img?.naturalWidth;
22 | }
23 | return img?.naturalHeight;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/index.module.less:
--------------------------------------------------------------------------------
1 | .markWrapper {
2 | position: absolute;
3 | top: auto;
4 | right: auto;
5 | left: auto;
6 | bottom: auto;
7 | z-index: 20;
8 | width: auto;
9 | height: auto;
10 | text-align: center;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/MarkLayer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { SVGProps } from "react";
2 | import classNames from "classnames";
3 | import SvgRect from "./SvgRect";
4 | import { IRectItem } from "../../types/keyVal";
5 | import styles from "./index.module.less";
6 |
7 | export interface IMarkLayerProps {
8 | className?: string;
9 | style?: React.CSSProperties;
10 | rects: IRectItem[];
11 | rate: number;
12 | activeContentId?: string;
13 | getContainer: () => HTMLElement | null | undefined;
14 | svgAttr?: SVGProps;
15 | onMouseDown?: (e: any) => void;
16 | }
17 |
18 | export default function MarkLayer({
19 | className,
20 | style,
21 | rects,
22 | rate,
23 | activeContentId,
24 | getContainer,
25 | svgAttr,
26 | onMouseDown,
27 | }: IMarkLayerProps) {
28 | return (
29 |
34 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/RadioGroup/index.module.less:
--------------------------------------------------------------------------------
1 | .radioGroup {
2 | display: flex;
3 | align-items: center;
4 | row-gap: 8px;
5 | }
6 |
7 | .radioButton {
8 | cursor: pointer;
9 | &:hover {
10 | .radioButtonLabel {
11 | color: #1a66ff;
12 | }
13 | }
14 | }
15 |
16 | .radioButtonLabel {
17 | display: flex;
18 | justify-content: center;
19 | padding: 4px 20px;
20 | border: 1px solid #dcdfe5;
21 | background-color: #fff;
22 | color: #030a1a;
23 | cursor: pointer;
24 | }
25 |
26 | .radioButtonLabelChecked {
27 | color: #1a66ff;
28 | border-color: #1a66ff;
29 | }
30 |
31 | .lineTypeRadioGroup {
32 | .radioButtonLabel {
33 | border: none;
34 | border-bottom: 1px solid #dcdfe5;
35 | }
36 | .radioButtonLabelChecked {
37 | border-width: 2px;
38 | border-color: #1a66ff;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/RadioGroup/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import React, { useState } from "react";
3 | import styles from "./index.module.less";
4 |
5 | interface RadioButtonProps {
6 | style?: React.CSSProperties;
7 | label: string;
8 | value: string;
9 | checked: boolean;
10 | onChange: () => void;
11 | labelProps?: Record;
12 | }
13 |
14 | const RadioButton: React.FC = ({
15 | style,
16 | label,
17 | value,
18 | checked,
19 | onChange,
20 | labelProps,
21 | }) => {
22 | return (
23 |
43 | );
44 | };
45 |
46 | interface RadioGroupProps {
47 | className?: string;
48 | style?: React.CSSProperties;
49 | optionStyle?: React.CSSProperties;
50 | options: { label: string; value: string; labelProps?: Record }[];
51 | defaultValue?: string;
52 | value?: string;
53 | onChange?: (value: string) => void;
54 | type?: "line" | "button";
55 | }
56 |
57 | export const RadioGroup: React.FC = ({
58 | className,
59 | style,
60 | optionStyle,
61 | options,
62 | defaultValue,
63 | value: propsValue,
64 | onChange,
65 | type = "button",
66 | }) => {
67 | const [_value, _setValue] = useState(defaultValue || options[0].value);
68 |
69 | const value = propsValue || _value;
70 |
71 | const handleChange = (val: string) => {
72 | _setValue(val);
73 | onChange?.(val);
74 | };
75 |
76 | return (
77 |
84 | {options.map((option) => (
85 | handleChange(option.value)}
92 | labelProps={option.labelProps}
93 | />
94 | ))}
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/src/components/ResultView/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return ;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/ResultView/Header.tsx:
--------------------------------------------------------------------------------
1 | interface IHederProps {
2 | width: number | any;
3 | valueWidth: number | any;
4 | }
5 | export default function Header({ width, valueWidth }: IHederProps) {
6 | return (
7 |
8 |
9 | 字段名
10 |
11 |
15 | 信息内容
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ResultView/KeyValueList.module.less:
--------------------------------------------------------------------------------
1 | @text-color: #030a1a;
2 |
3 | .container {
4 | overflow: hidden;
5 | &:not(:last-child) {
6 | margin-bottom: 16px;
7 | }
8 | :global {
9 | .rowWrap {
10 | position: relative;
11 | margin-top: 16px;
12 | .robot-result-item {
13 | &:not(:last-child) {
14 | margin-bottom: 8px;
15 | }
16 | .result-item-key {
17 | padding-left: 10px;
18 | color: #a2a8b2;
19 | font-size: 12px;
20 | }
21 | }
22 | .robot-result-item textarea {
23 | color: @text-color;
24 | font-size: 12px;
25 | }
26 |
27 | &::before {
28 | position: absolute;
29 | top: 0;
30 | bottom: 0;
31 | left: 20px;
32 | width: 2px;
33 | background: #e4e4eb;
34 | border-radius: 1;
35 | content: "";
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/ResultView/KeyValueList.tsx:
--------------------------------------------------------------------------------
1 | import type { IFieldItem } from "../../types/keyVal";
2 | import { Field } from "./KeyValueTable";
3 | import styles from "./KeyValueList.module.less";
4 |
5 | interface IKeyValueListProps {
6 | list: IFieldItem[][];
7 | width?: number;
8 | valueWidth?: number;
9 | disabled?: boolean;
10 | activeContentId?: string;
11 | onClick?: (e: any) => void;
12 | getContainer: () => HTMLElement | null | undefined;
13 | }
14 | export default function KeyValueList({
15 | list,
16 | width,
17 | valueWidth,
18 | disabled,
19 | onClick,
20 | activeContentId,
21 | getContainer,
22 | }: IKeyValueListProps) {
23 | return (
24 |
25 | {list.map((rowItem, idx) => {
26 | return (
27 |
28 | {rowItem.map((row) => (
29 | onClick?.(row.uid)}
38 | />
39 | ))}
40 |
41 | );
42 | })}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ResultView/KeyValueTable.module.less:
--------------------------------------------------------------------------------
1 | @text-color: #030a1a;
2 | @text-gray-color: #a2a8b2;
3 | @primary-color: #1a66ff;
4 |
5 | .tableWrapper {
6 | overflow: hidden;
7 | }
8 | :global {
9 | .robot-result-item {
10 | display: flex;
11 | margin-bottom: 12px;
12 | padding-left: 20px;
13 | color: @text-color;
14 | font-size: 14px;
15 | line-height: 18px;
16 | .result-item-key {
17 | color: @text-gray-color;
18 | font-size: 13px;
19 | cursor: text;
20 | .ant-input-disabled {
21 | background-color: #fff;
22 | }
23 | }
24 | .result-item-key {
25 | flex-shrink: 0;
26 | min-width: 80px;
27 | max-width: 50%;
28 | margin-right: 20px;
29 | white-space: pre;
30 | }
31 | .result-item-value {
32 | min-width: 50%;
33 | &.active {
34 | color: @primary-color;
35 | }
36 | }
37 | &.active {
38 | .result-item-value {
39 | color: @primary-color;
40 | }
41 | }
42 | img {
43 | max-width: 100%;
44 | margin: 9px 0;
45 | }
46 | input,
47 | textarea {
48 | min-height: 20px;
49 | padding: 0;
50 | color: @text-color;
51 | border: none;
52 | outline: none;
53 | box-shadow: none !important;
54 | resize: none;
55 | &:focus {
56 | color: @primary-color !important;
57 | }
58 | &[disabled] {
59 | color: @text-color;
60 | background-color: #fff;
61 | cursor: text;
62 | }
63 | }
64 |
65 | &:last-child {
66 | margin-bottom: 0px;
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/ResultView/KeyValueTable.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef } from "react";
2 | import classNames from "classnames";
3 | import { scrollIntoViewIfNeeded } from "../../utils/dom";
4 | import { IFieldItem } from "../../types/keyVal";
5 | import styles from "./KeyValueTable.module.less";
6 |
7 | export interface IKeyValueTableProps {
8 | dataSource: IFieldItem[];
9 | width?: number;
10 | valueWidth?: number;
11 | activeContentId?: string;
12 | getContainer: () => HTMLElement | null | undefined;
13 | }
14 |
15 | export default function KeyValueTable({
16 | dataSource,
17 | width,
18 | valueWidth,
19 | activeContentId,
20 | getContainer,
21 | }: IKeyValueTableProps) {
22 | return (
23 |
24 | {dataSource.map(({ key, ...rowItem }) => (
25 |
33 | ))}
34 |
35 | );
36 | }
37 |
38 | interface IFieldProps extends Pick {
39 | parent_uid?: string;
40 | uid: string;
41 | width?: number | any;
42 | valueWidth?: any;
43 | disabled?: boolean;
44 | active?: boolean;
45 | onClick?: (e: any) => void;
46 | getContainer: () => HTMLElement | null | undefined;
47 | }
48 |
49 | export function Field({
50 | parent_uid,
51 | uid,
52 | value,
53 | description,
54 | width,
55 | valueWidth,
56 | active,
57 | onClick,
58 | getContainer,
59 | }: IFieldProps) {
60 | const rowRef = useRef(null);
61 |
62 | useLayoutEffect(() => {
63 | if (active && rowRef.current) {
64 | const container = getContainer();
65 | const scrollOptions: ScrollIntoViewOptions = {
66 | block: "nearest",
67 | inline: "nearest",
68 | };
69 | if (container) {
70 | scrollIntoViewIfNeeded(
71 | rowRef.current,
72 | container,
73 | scrollOptions,
74 | rowRef.current.getBoundingClientRect().height
75 | );
76 | } else {
77 | rowRef.current.scrollIntoView(scrollOptions);
78 | }
79 | }
80 | }, [active]);
81 |
82 | return (
83 |
90 |
91 | {description}
92 |
93 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/ResultView/index.module.less:
--------------------------------------------------------------------------------
1 | .resultContainer {
2 | display: flex;
3 | flex-direction: column;
4 | height: 100%;
5 | text-align: left;
6 | .radioGroup {
7 | display: flex;
8 | flex-wrap: wrap;
9 | row-gap: 8px;
10 | column-gap: 4px;
11 | white-space: nowrap;
12 | margin-bottom: 8px;
13 | :global {
14 | .radioButton {
15 | flex: 1;
16 | }
17 | }
18 | }
19 | .contentContainer {
20 | position: relative;
21 | display: flex;
22 | flex: 1;
23 | flex-direction: column;
24 | overflow: hidden;
25 | .content {
26 | flex: 1;
27 | overflow-x: hidden;
28 | overflow-y: auto;
29 | }
30 |
31 | [data-page-number]:not(:only-child):not(.hidden-page):not(:empty):has(
32 | > :not(.hidden)
33 | ):after {
34 | display: block;
35 | margin: 8px 0 20px;
36 | color: #858c99;
37 | font-size: 12px;
38 | text-align: center;
39 | border-bottom: 1px solid #eee;
40 | content: "[\7b2c"attr(data-page-number) "\9875]";
41 | -webkit-user-select: none;
42 | -moz-user-select: none;
43 | -ms-user-select: none;
44 | user-select: none;
45 | }
46 | }
47 |
48 | :global {
49 | .desc {
50 | padding: 9px 0;
51 | padding-left: 20px;
52 | color: rgb(72, 119, 255);
53 | font-size: 14px;
54 | line-height: 22px;
55 | background-color: rgba(72, 119, 255, 0.1);
56 | }
57 | .result-title {
58 | display: flex;
59 | margin-bottom: 12px;
60 | line-height: 36px;
61 | background-color: #f2f5fa;
62 | padding: 0 20px;
63 | .result-title-key {
64 | flex-shrink: 0;
65 | min-width: 3em;
66 | max-width: 50%;
67 | margin-right: 20px;
68 | white-space: pre;
69 | }
70 | .result-title-key,
71 | .result-title-value {
72 | // flex: 1;
73 | color: #a2a8b2;
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/ResultView/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 解析结果展示组件
3 | *
4 | */
5 |
6 | import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react";
7 | import classNames from "classnames";
8 | import { RadioGroup } from "../RadioGroup";
9 | import { IResultListItem } from "../../types/keyVal";
10 | import { ensureArray } from "../../utils/object";
11 | import Header from "./Header";
12 | import KeyValueTable from "./KeyValueTable";
13 | import KeyValueList from "./KeyValueList";
14 | import styles from "./index.module.less";
15 | import { BlockLoading } from "../Loading";
16 |
17 | export interface IResultViewProps {
18 | className?: string;
19 | style?: React.CSSProperties;
20 | resultList?: IResultListItem[];
21 | getContainerRef?: React.RefObject;
22 | activeContentId?: string;
23 | activeParentContentId?: string;
24 | loading?: boolean;
25 | loadingComponent?: ReactNode;
26 | }
27 |
28 | export default function ResultView({
29 | className,
30 | style,
31 | resultList,
32 | getContainerRef,
33 | activeContentId,
34 | activeParentContentId,
35 | loading,
36 | loadingComponent,
37 | }: IResultViewProps) {
38 | const [activeItemId, setActiveItemId] = useState(resultList?.[0]?.uid);
39 | const changeActiveItem = (value: string) => {
40 | setActiveItemId(value);
41 | };
42 | const activeItemResult = useMemo(
43 | () =>
44 | ensureArray(resultList).find(
45 | (item) => item.uid === activeItemId
46 | ),
47 | [activeItemId, resultList]
48 | );
49 |
50 | useEffect(() => {
51 | if (activeParentContentId && activeParentContentId !== activeItemId) {
52 | changeActiveItem(activeParentContentId);
53 | }
54 | }, [activeParentContentId]);
55 |
56 | const refValue = useRef();
57 | const [width, setWidth] = useState();
58 | const [valueWidth, setValueWidth] = useState();
59 |
60 | useEffect(() => {
61 | const dataSource = [...(activeItemResult?.list || [])];
62 | if (activeItemResult?.flightList?.length) {
63 | const list = activeItemResult?.flightList.reduce(
64 | (pre, cur) => [...pre, ...cur],
65 | []
66 | );
67 | dataSource.push(...list);
68 | }
69 | if (dataSource.length > 0) {
70 | const arr: any[] = [];
71 | dataSource.forEach((item) => {
72 | const count: number = countCharacters(item.description);
73 | arr.push(count);
74 | });
75 | const maxKey = Math.max(...arr);
76 | setWidth((maxKey + 2) * 6);
77 | const domValueWidth = refValue.current
78 | ? refValue.current?.offsetWidth
79 | : 0;
80 | setValueWidth(domValueWidth);
81 | }
82 | }, [activeItemResult]);
83 |
84 | const countCharacters = (str: string = "") => {
85 | let totalCount = 0;
86 | for (let i = 0; i < str.length; i++) {
87 | const c = str.charCodeAt(i);
88 | if ((c >= 0x0001 && c <= 0x007e) || (0xff60 <= c && c <= 0xff9f)) {
89 | totalCount++;
90 | } else {
91 | totalCount += 2;
92 | }
93 | }
94 | return totalCount;
95 | };
96 |
97 | const splitIndex = useMemo(() => {
98 | if (
99 | !activeItemResult ||
100 | !(
101 | Array.isArray(activeItemResult.flightList) &&
102 | activeItemResult.flightList.length
103 | )
104 | )
105 | return -1;
106 | return activeItemResult.flightList[0]?.some((i) => i.value)
107 | ? activeItemResult.list?.findIndex((i) => i.value === "")
108 | : -1;
109 | }, [activeItemResult]);
110 |
111 | return (
112 | }
116 | >
117 |
({
122 | label: `${item.no} ${item.description}`,
123 | value: item.uid,
124 | labelProps: {
125 | "data-content-id": item.uid,
126 | },
127 | }))}
128 | />
129 |
130 | {activeItemResult && (
131 | <>
132 |
133 |
138 | 0 ? splitIndex : undefined
142 | )}
143 | width={width}
144 | valueWidth={valueWidth}
145 | activeContentId={activeContentId}
146 | getContainer={() => getContainerRef?.current}
147 | />
148 | getContainerRef?.current}
154 | />
155 | {splitIndex > 0 && (
156 | getContainerRef?.current}
162 | />
163 | )}
164 |
165 | >
166 | )}
167 |
168 | {loading && (loadingComponent || )}
169 |
170 | );
171 | }
172 |
--------------------------------------------------------------------------------
/src/examples/ImageExample.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef, useState } from "react";
2 | import FilePreview from "../components/FilePreview";
3 | import { RadioGroup } from "../components/RadioGroup";
4 | import ResultView from "../components/ResultView";
5 | import { imageExample } from "./data";
6 | import JsonView from "../components/JsonView";
7 | import { useContentLinkage } from "../hooks/useContentLinkage";
8 |
9 | export default function ImageExample() {
10 | const [resultTab, setResultTab] = useState("text");
11 | const viewContainerRef = useRef(null);
12 | const resultContainerRef = useRef(null);
13 |
14 | const { activeParentContentId, activeContentId, registerLinkage } =
15 | useContentLinkage({
16 | viewContainerRef,
17 | resultContainerRef,
18 | });
19 |
20 | useLayoutEffect(() => {
21 | registerLinkage();
22 | }, []);
23 |
24 | return (
25 |
35 |
36 |
预览
37 |
46 |
53 |
54 |
55 |
56 |
67 | {resultTab === "text" && (
68 |
82 | )}
83 | {resultTab === "json" && (
84 |
92 | )}
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/examples/PDFExample.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef, useState } from "react";
2 | import FilePreview from "../components/FilePreview";
3 | import { RadioGroup } from "../components/RadioGroup";
4 | import ResultView from "../components/ResultView";
5 | import { pdfExample } from "./data";
6 | import JsonView from "../components/JsonView";
7 | import { useContentLinkage } from "../hooks/useContentLinkage";
8 |
9 | export default function PDFExample() {
10 | const [resultTab, setResultTab] = useState("text");
11 | const viewContainerRef = useRef(null);
12 | const resultContainerRef = useRef(null);
13 |
14 | const { activeParentContentId, activeContentId, registerLinkage } =
15 | useContentLinkage({
16 | viewContainerRef,
17 | resultContainerRef,
18 | });
19 |
20 | useLayoutEffect(() => {
21 | registerLinkage();
22 | }, []);
23 |
24 | return (
25 |
35 |
36 |
预览
37 |
46 |
55 |
56 |
57 |
58 |
69 | {resultTab === "text" && (
70 |
83 | )}
84 | {resultTab === "json" && (
85 |
93 | )}
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/examples/data.ts:
--------------------------------------------------------------------------------
1 | import { IOriginResult } from "../types/keyVal";
2 | import { transformKeyValApiResultToView } from "../utils/transformers";
3 | import imageExampleJson from "./image_example.json";
4 | import pdfExampleJson from "./pdf_example.json";
5 |
6 | // 图片示例
7 | export const imageExample = {
8 | src: [
9 | "https://web-api.textin.com/open/image/download?filename=62f87022e581449081b718f9c1cd9296",
10 | ],
11 | json: imageExampleJson,
12 | ...transformKeyValApiResultToView(imageExampleJson as IOriginResult),
13 | };
14 |
15 | // pdf示例
16 | export const pdfExample = {
17 | src: "https://web-api.textin.com/open/image/download?filename=17c9fe6ace284ea1b99912b58bd674ed",
18 | json: pdfExampleJson,
19 | ...transformKeyValApiResultToView(pdfExampleJson as IOriginResult),
20 | };
21 |
--------------------------------------------------------------------------------
/src/hooks/useContentLinkage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 预览与解析结果联动逻辑
3 | */
4 |
5 | import React, { useState } from "react";
6 |
7 | export const useContentLinkage = ({
8 | viewContainerRef,
9 | resultContainerRef,
10 | }: {
11 | viewContainerRef: React.RefObject;
12 | resultContainerRef: React.RefObject;
13 | }) => {
14 | const [activeContentId, setActiveContentId] = useState("");
15 | const [activeParentContentId, setActiveParentContentId] = useState("");
16 |
17 | const registerLinkage = () => {
18 | if (!viewContainerRef.current || !resultContainerRef.current) {
19 | return;
20 | }
21 | viewContainerRef.current.addEventListener("click", handleClick);
22 | resultContainerRef.current.addEventListener("click", handleClick);
23 | };
24 |
25 | const handleClick = (e: any) => {
26 | const targetContent = findTargetContentId(e.target, e.currentTarget);
27 | const targetContentId = targetContent?.dataset?.contentId;
28 | const targetParentContentId =
29 | targetContent?.dataset?.parentContentId || targetContentId;
30 | if (targetParentContentId) {
31 | setActiveParentContentId(targetParentContentId);
32 | }
33 |
34 | if (targetContentId) {
35 | setActiveContentId(targetContentId);
36 | }
37 | };
38 |
39 | const findTargetContentId = (
40 | target: HTMLElement,
41 | container: HTMLElement
42 | ): HTMLElement | null => {
43 | let currentElement: HTMLElement | null = target;
44 | while (currentElement && currentElement !== container) {
45 | if (currentElement.hasAttribute("data-content-id")) {
46 | return currentElement;
47 | }
48 | currentElement = currentElement.parentElement;
49 | }
50 | return null;
51 | };
52 |
53 | return {
54 | activeContentId,
55 | setActiveContentId,
56 | activeParentContentId,
57 | setActiveParentContentId,
58 | registerLinkage,
59 | };
60 | };
61 |
--------------------------------------------------------------------------------
/src/hooks/useFrameSetState.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useEffect } from "react";
2 |
3 | type SetActionType = Partial | ((state: T) => Partial);
4 |
5 | export default function useFrameSetState(initial: T) {
6 | const frame = useRef(null);
7 | const [state, setState] = useState(initial);
8 | const queue = useRef[]>([]);
9 |
10 | const setFrameState = (newState: SetActionType) => {
11 | queue.current.push(newState);
12 |
13 | if (frame.current === null) {
14 | frame.current = requestAnimationFrame(() => {
15 | setState((prevState) => {
16 | let memoState: any = prevState;
17 | queue.current.forEach((queueState) => {
18 | if (typeof queueState === "function") {
19 | memoState = { ...memoState, ...queueState(memoState) };
20 | } else {
21 | memoState = { ...memoState, ...queueState };
22 | }
23 | });
24 | queue.current = []; // 清空队列
25 | frame.current = null; // 重置帧
26 | return memoState;
27 | });
28 | });
29 | }
30 | };
31 |
32 | useEffect(() => {
33 | return () => {
34 | if (frame.current) {
35 | cancelAnimationFrame(frame.current);
36 | }
37 | };
38 | }, []);
39 |
40 | return [state, setFrameState] as const;
41 | }
42 |
--------------------------------------------------------------------------------
/src/hooks/useLoadPDFLib.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from "react";
2 | import { useExternal } from "ahooks";
3 | import { isIEBrowser } from "../utils/browser";
4 |
5 | export const useLoadPDFLib = ({
6 | onLoad,
7 | onError,
8 | }: { onLoad?: () => void; onError?: () => void } = {}) => {
9 | const [pdfLibReady, setPdfLibReady] = useState(
10 | () => !!(window.pdfjsLib && window.pdfjsWorker)
11 | );
12 |
13 | const { dir, buildDir, cmapsURL, getParams } = useMemo(() => {
14 | //TODO: 内置到本地
15 | const versionMap = {
16 | new: "https://static.textin.com/deps/pdfjs-dist@2.11.338",
17 | old: "https://static.textin.com/deps/pdfjs-dist@2.0.943",
18 | };
19 | const isOld = isIEBrowser || !document.body?.attachShadow;
20 | const dir = isOld ? versionMap.old : versionMap.new;
21 | return {
22 | dir,
23 | buildDir: dir + (isOld ? "" : "/legacy"),
24 | getParams: (scale: number) => (isOld ? scale : { scale }),
25 | cmapsURL: `${dir}/cmaps/`,
26 | };
27 | }, []);
28 |
29 | // 兼容IE 11 的版本
30 | const status1 = useExternal(`${buildDir}/build/pdf.min.js`);
31 | const status2 = useExternal(`${buildDir}/build/pdf.worker.min.js`);
32 |
33 | useEffect(() => {
34 | if (status1 === "error" || status2 === "error") {
35 | onError?.();
36 | setPdfLibReady(false);
37 | console.error("pdf load error");
38 | }
39 | }, [status1, status2]);
40 |
41 | useEffect(() => {
42 | (async () => {
43 | if (window.pdfjsLib && window.pdfjsWorker && !pdfLibReady) {
44 | setPdfLibReady(true);
45 | onLoad?.();
46 | }
47 | })();
48 | }, [status1, status2]);
49 |
50 | return { pdfLibReady, dir, buildDir, cmapsURL, getParams };
51 | };
52 |
--------------------------------------------------------------------------------
/src/hooks/useMarkTool.ts:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 | import { useRef, useState } from "react";
3 | import {
4 | useSize,
5 | useDebounceFn,
6 | useUpdateEffect,
7 | useEventListener,
8 | } from "ahooks";
9 | import { getImgWidth } from "../components/MarkLayer/helpers";
10 |
11 | interface IPositionStyle {
12 | width: number;
13 | height: number;
14 | left: number;
15 | top: number;
16 | }
17 | interface MarkToolOptions {
18 | viewContainerRef?: React.RefObject;
19 | imgRef: React.MutableRefObject;
20 | angle?: number;
21 | angleFix?: boolean;
22 | onSizeChange?: () => void;
23 | clearList?: () => void;
24 | resizeScale?: () => void;
25 | }
26 | export default function useMarkTool({
27 | viewContainerRef,
28 | imgRef,
29 | angle,
30 | angleFix,
31 | onSizeChange = () => {},
32 | resizeScale,
33 | clearList = () => {},
34 | }: MarkToolOptions) {
35 | const containerSize = useSize(viewContainerRef);
36 | const isClear = useRef(false);
37 |
38 | const [renderRate, setRenderRate] = useState(0); // 原图尺寸与渲染尺寸比例
39 | const [markStyle, setMarkStyle] = useState({
40 | width: 0,
41 | height: 0,
42 | left: 0,
43 | top: 0,
44 | });
45 | const winSizeFn = useDebounceFn(
46 | () => {
47 | isClear.current = false;
48 | updateMark();
49 | onSizeChange();
50 | },
51 | { wait: 0 }
52 | );
53 |
54 | // 监听容器尺寸变化,如果改变则清除画框,重置旋转角度
55 | useUpdateEffect(() => {
56 | winSizeFn.run();
57 | if (!isClear.current) {
58 | resizeScale?.();
59 | isClear.current = true;
60 | clearList();
61 | }
62 | }, [containerSize, angle]);
63 |
64 | // 有旋转角度时,监听旋转结束后更新蒙层位置
65 | useEventListener(
66 | "transitionend",
67 | () => {
68 | if (angle === 90 || angle === 270) {
69 | updateMark();
70 | if (onSizeChange) {
71 | onSizeChange();
72 | }
73 | }
74 | },
75 | { target: imgRef.current || undefined }
76 | );
77 |
78 | function updateMark() {
79 | if (!imgRef.current) return;
80 | updateRenderRate();
81 | updateMarkStyle();
82 | }
83 |
84 | const updateRenderRate = () => {
85 | if (!imgRef.current) {
86 | return;
87 | }
88 | const imgNaturalWidth = getImgWidth({}, imgRef.current);
89 | const imgRenderWidth = imgRef.current.offsetWidth;
90 | setRenderRate(imgRenderWidth / imgNaturalWidth);
91 | };
92 |
93 | const updateMarkStyle = () => {
94 | if (!imgRef.current) {
95 | return;
96 | }
97 | const { offsetLeft: left, offsetTop: top } = imgRef.current;
98 | const { width, height } = imgRef.current.getBoundingClientRect();
99 |
100 | let currentStyle = {
101 | left,
102 | top,
103 | width,
104 | height,
105 | };
106 | // 旋转角度 width <=> height 补位
107 | if (angleFix && angle && [90, 270].includes(angle)) {
108 | const diff = (width - height) / 2;
109 | currentStyle = {
110 | width: height,
111 | height: width,
112 | left: left + diff,
113 | top: top - diff,
114 | };
115 | }
116 | setMarkStyle(currentStyle);
117 | };
118 |
119 | return { renderRate, markStyle, updateMark };
120 | }
121 |
--------------------------------------------------------------------------------
/src/hooks/usePDFMarkLayer.ts:
--------------------------------------------------------------------------------
1 | import { IPageItem, IRectItem } from "../types/keyVal";
2 | import { useCallback, useEffect } from "react";
3 | import { scrollIntoViewIfNeeded } from "../utils/dom";
4 |
5 | const svgNS = "http://www.w3.org/2000/svg";
6 | const rectClass = "rectLayer";
7 |
8 | let observer: MutationObserver;
9 |
10 | interface PDFMarkLayerOptions {
11 | containerRef: React.RefObject;
12 | pdfViewerRef?: React.RefObject;
13 | rects?: IRectItem[][];
14 | pages: IPageItem[];
15 | dpi?: number;
16 | activeContentId?: string;
17 | showMark?: boolean;
18 | }
19 |
20 | export const usePDFMarkLayer = ({
21 | containerRef,
22 | pdfViewerRef,
23 | rects,
24 | pages,
25 | dpi,
26 | activeContentId,
27 | showMark = true,
28 | }: PDFMarkLayerOptions) => {
29 | const pdfViewDpi = 96;
30 | let resultDpi = dpi || 144;
31 | let dpiScale = pdfViewDpi / resultDpi;
32 |
33 | function createPageMark(
34 | pageItem: HTMLDivElement,
35 | { activeId }: { activeId?: string }
36 | ) {
37 | const page = pageItem.dataset.pageNumber;
38 | const { clientWidth, clientHeight } = pageItem;
39 | const scale = pdfViewerRef?.current.currentScale;
40 | const pageIndex = Number(page) - 1;
41 | const curPageRects = rects![pageIndex];
42 | if (!dpi && Array.isArray(pages)) {
43 | const curPage = pages[pageIndex] || {};
44 | if (curPage.ppi && typeof curPage.ppi === "number") {
45 | resultDpi = curPage.ppi;
46 | } else if (
47 | typeof curPage.width === "number" &&
48 | typeof curPage.height === "number"
49 | ) {
50 | let { width: resultWidth, height: resultHeight } = curPage;
51 | const { viewHeight, viewWidth } = [90, 270].includes(
52 | pdfViewerRef?.current.pagesRotation
53 | )
54 | ? { viewHeight: clientWidth, viewWidth: clientHeight }
55 | : { viewHeight: clientHeight, viewWidth: clientWidth };
56 | // 判断结果中的width/height是否反了
57 | const sizeRate = viewWidth / viewHeight;
58 | if (
59 | [90, 270].includes(curPage.angle || 0) &&
60 | Math.abs(curPage.width / curPage.height - sizeRate) > 0.02 &&
61 | Math.abs(curPage.height / curPage.width - sizeRate) <= 0.02
62 | ) {
63 | resultWidth = curPage.height;
64 | resultHeight = curPage.width;
65 | }
66 | resultDpi = Math.round(
67 | pdfViewDpi * (resultWidth / (viewWidth / scale))
68 | );
69 | }
70 | dpiScale = pdfViewDpi / resultDpi;
71 | }
72 | if (page && Array.isArray(curPageRects) && curPageRects.length) {
73 | const oldDom = pageItem.querySelector(`.${rectClass}`);
74 | if (oldDom) {
75 | oldDom.remove();
76 | }
77 | const svgDom = document.createElementNS(svgNS, "svg");
78 | const pageAngle = curPageRects[0].angle || 0;
79 | const rotate = (pdfViewerRef?.current.pagesRotation + pageAngle) % 360;
80 | let info = {
81 | width: clientWidth,
82 | height: clientHeight,
83 | translateX: 0,
84 | translateY: 0,
85 | };
86 | if (rotate === 90) {
87 | info = {
88 | width: clientHeight,
89 | height: clientWidth,
90 | translateX: 0,
91 | translateY: -clientWidth,
92 | };
93 | } else if (rotate === 180) {
94 | info = {
95 | width: clientWidth,
96 | height: clientHeight,
97 | translateX: -clientWidth,
98 | translateY: -clientHeight,
99 | };
100 | } else if (rotate === 270) {
101 | info = {
102 | width: clientHeight,
103 | height: clientWidth,
104 | translateX: -clientHeight,
105 | translateY: 0,
106 | };
107 | }
108 | svgDom.setAttribute("data-dpi-scale", `${dpiScale}`);
109 | svgDom.setAttribute("data-angle", `${pageAngle}`);
110 | svgDom.setAttribute("class", `${rectClass}`);
111 | svgDom.setAttribute("width", `${info.width}`);
112 | svgDom.setAttribute("height", `${info.height}`);
113 | svgDom.setAttribute(
114 | "style",
115 | `transform: rotate(${rotate}deg) translate3d(${info.translateX}px, ${info.translateY}px, 0)`
116 | );
117 | const viewBoxWidth = Number((info.width / scale / dpiScale).toFixed(2));
118 | const viewBoxHeight = Number((info.height / scale / dpiScale).toFixed(2));
119 | svgDom.setAttribute("viewBox", `0 0 ${viewBoxWidth} ${viewBoxHeight}`);
120 | let isOver = false;
121 | curPageRects.forEach((rect) => {
122 | if (!rect.position) return;
123 | const polygon = document.createElementNS(svgNS, "polygon");
124 | polygon.setAttribute(
125 | "points",
126 | rect.position.reduce(
127 | (pre, cur, i) => pre + (i % 2 ? "," : " ") + cur,
128 | ""
129 | )
130 | );
131 | polygon.setAttribute("vector-effect", "non-scaling-stroke");
132 | polygon.setAttribute("data-content-id", `${rect.content_id}`);
133 | if (rect.parent_id) {
134 | polygon.setAttribute("data-parent-content-id", `${rect.parent_id}`);
135 | }
136 | if (activeId && activeId === `${rect.content_id}`) {
137 | polygon.classList.add("active");
138 | }
139 | const rect_type = rect.rect_type || rect.type;
140 | if (rect_type) {
141 | polygon.classList.add(rect_type);
142 | }
143 | const textScale = 1 / scale / dpiScale;
144 | if (rect.render_text) {
145 | const renderGroup = document.createElementNS(svgNS, "g");
146 | const translateScale = (1 - textScale) / textScale;
147 | renderGroup.setAttribute(
148 | "style",
149 | `transform: scale(${textScale}) translate(${
150 | rect.position[0] * translateScale
151 | }px, ${rect.position[1] * translateScale}px)`
152 | );
153 | const renderRect = document.createElementNS(svgNS, "rect");
154 | const attr1: any = {
155 | width: "16",
156 | height: "16",
157 | fill: "#4877FF",
158 | x: `${rect.position[0]}`,
159 | y: `${rect.position[1]}`,
160 | };
161 | for (const attr in attr1) {
162 | renderRect.setAttribute(attr, attr1[attr]);
163 | }
164 | renderGroup.appendChild(renderRect);
165 | const renderText = document.createElementNS(svgNS, "text");
166 | const attr2: any = {
167 | style: "font-size: 12px; fill: #fff",
168 | x: `${rect.position[0] + 4}`,
169 | y: `${rect.position[1] + 11}`,
170 | };
171 | for (const attr in attr2) {
172 | renderText.setAttribute(attr, attr2[attr]);
173 | }
174 | renderText.textContent = rect.render_text;
175 | renderGroup.appendChild(renderText);
176 | svgDom.appendChild(renderGroup);
177 | }
178 | svgDom.appendChild(polygon);
179 |
180 | if (!isOver) {
181 | isOver =
182 | rect.position[2] - viewBoxWidth > 5 ||
183 | rect.position[4] - viewBoxWidth > 5 ||
184 | rect.position[5] - viewBoxHeight > 5 ||
185 | rect.position[7] - viewBoxHeight > 5;
186 | // console.log('isOver', { rect, viewBoxHeight, viewBoxWidth });
187 | }
188 | });
189 | if (!isOver) {
190 | pageItem.insertBefore(
191 | svgDom,
192 | pageItem.children[1] ||
193 | pageItem.children[pageItem.children.length - 1]
194 | );
195 | }
196 | }
197 | }
198 |
199 | function removeExpiredMark() {
200 | const oldDoms: HTMLDivElement[] = containerRef.current.querySelectorAll(
201 | `.page:not([data-loaded="true"]) .${rectClass}`
202 | );
203 | oldDoms.forEach((item) => {
204 | item.remove();
205 | });
206 | }
207 |
208 | function createMark(list: any[]) {
209 | try {
210 | if (list.forEach) {
211 | list.forEach((pageItem) => {
212 | createPageMark(pageItem, { activeId: activeContentId });
213 | });
214 | }
215 | removeExpiredMark();
216 | } catch (error) {
217 | console.log("MutationObserver callback error", error);
218 | }
219 | }
220 |
221 | function removeOldMark() {
222 | const oldPagesMark: HTMLDivElement[] =
223 | containerRef.current.querySelectorAll(`.${rectClass}`);
224 | oldPagesMark.forEach((item) => {
225 | item.remove();
226 | });
227 | }
228 |
229 | const run = useCallback(() => {
230 | if (
231 | !containerRef.current ||
232 | !pdfViewerRef?.current ||
233 | !rects ||
234 | !MutationObserver
235 | ) {
236 | return;
237 | }
238 | removeOldMark();
239 | if (!showMark) {
240 | return;
241 | }
242 | const initDoms = containerRef.current.querySelectorAll(
243 | '.page[data-loaded="true"]'
244 | );
245 | createMark(initDoms);
246 |
247 | // 监听pdf页面加载(data-loaded=true),绘制页面回显
248 | if (observer && observer.disconnect) {
249 | observer.disconnect();
250 | }
251 | observer = new MutationObserver((pages: MutationRecord[]) => {
252 | const pageList = pages.map ? pages.map((item) => item.target) : [];
253 | createMark(pageList);
254 | });
255 | const config = { attributeFilter: ["data-loaded"], subtree: true };
256 | observer.observe(containerRef.current, config);
257 | }, [showMark, rects]);
258 |
259 | useEffect(() => {
260 | run();
261 | }, [run]);
262 |
263 | const removeActiveClass = () => {
264 | const activeEles = Array.from(
265 | containerRef.current.querySelectorAll("[data-content-id].active")
266 | );
267 | activeEles.forEach((ele: any) => ele.classList.remove("active"));
268 | };
269 |
270 | const addActiveClassAndScrollTo = (contentId: string) => {
271 | const targetContent = containerRef.current.querySelector(
272 | `[data-content-id="${contentId}"]`
273 | );
274 | if (targetContent) {
275 | targetContent.classList.add("active");
276 | scrollIntoViewIfNeeded(
277 | targetContent,
278 | containerRef.current,
279 | { block: "nearest", inline: "nearest" },
280 | targetContent.getBoundingClientRect().height
281 | );
282 | }
283 | };
284 |
285 | useEffect(() => {
286 | if (activeContentId && containerRef.current) {
287 | removeActiveClass();
288 | addActiveClassAndScrollTo(activeContentId);
289 | }
290 | }, [activeContentId]);
291 |
292 | return { run };
293 | };
294 |
--------------------------------------------------------------------------------
/src/hooks/usePreviewTool.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 | import { useThrottleEffect } from "ahooks";
3 | import RotateLeft from "../assets/icon_img_rotate-90_default.svg?react";
4 | import RotateRight from "../assets/icon_img_rotate+90_default.svg?react";
5 | import ZoomIn from "../assets/icon_img_enlarge_default.svg?react";
6 | import ZoomOut from "../assets/icon_img_narrow_default.svg?react";
7 | import Normal from "../assets/icon_img_normal_default.svg?react";
8 |
9 | export interface PreviewToolItem {
10 | type: string;
11 | Icon?: React.FC>;
12 | disabled?: boolean;
13 | onClick?: () => void;
14 | }
15 |
16 | export interface ToolbarOptions {
17 | zoomStep?: number;
18 | maxScale?: number;
19 | }
20 |
21 | export interface PreviewToolOptions {
22 | viewContainerRef: React.RefObject;
23 | viewRef: React.RefObject;
24 | toolbarOptions?: ToolbarOptions;
25 | }
26 |
27 | export const usePreviewTool = ({
28 | viewContainerRef,
29 | viewRef,
30 | toolbarOptions,
31 | }: PreviewToolOptions) => {
32 | const initialPosition = {
33 | x: 0,
34 | y: 0,
35 | };
36 |
37 | const MAX_SCALE_VALUE = toolbarOptions?.maxScale || 4;
38 | const ZOOM_STEP = toolbarOptions?.zoomStep || 0.25;
39 |
40 | const [scale, setScale] = useState(1);
41 | const [rotate, setRotate] = useState(0);
42 | const [position, setPosition] = useState<{
43 | x: number;
44 | y: number;
45 | }>(initialPosition);
46 |
47 | const originPositionRef = useRef<{
48 | originX: number;
49 | originY: number;
50 | deltaX: number;
51 | deltaY: number;
52 | }>({
53 | originX: 0,
54 | originY: 0,
55 | deltaX: 0,
56 | deltaY: 0,
57 | });
58 | const [isMoving, setMoving] = useState(false);
59 |
60 | // img tools
61 | const onZoomIn = () => {
62 | if (scale < MAX_SCALE_VALUE) {
63 | setScale((value) => value + ZOOM_STEP);
64 | }
65 | setPosition(initialPosition);
66 | };
67 |
68 | const onZoomOut = () => {
69 | if (scale > 1) {
70 | setScale((value) => value - ZOOM_STEP);
71 | }
72 | setPosition(initialPosition);
73 | };
74 |
75 | const onRotateNormal = () => {
76 | clear();
77 | };
78 | const onRotateRight = () => {
79 | setRotate((value) => value + 90);
80 | };
81 |
82 | const onRotateLeft = () => {
83 | setRotate((value) => value - 90);
84 | };
85 | const clear = () => {
86 | setPosition(initialPosition);
87 | resizeScale();
88 | setMoving(false);
89 | setRotate(0);
90 | };
91 | const resizeScale = () => setScale(1);
92 |
93 | const tools: PreviewToolItem[] = [
94 | {
95 | Icon: ZoomOut,
96 | onClick: onZoomOut,
97 | type: "zoomOut",
98 | disabled: scale === 1,
99 | },
100 | {
101 | Icon: ZoomIn,
102 | onClick: onZoomIn,
103 | type: "zoomIn",
104 | disabled: scale === MAX_SCALE_VALUE,
105 | },
106 | {
107 | Icon: Normal,
108 | onClick: onRotateNormal,
109 | type: "normal",
110 | disabled: scale === 1,
111 | },
112 | {
113 | disabled: true,
114 | type: "line",
115 | },
116 | {
117 | Icon: RotateLeft,
118 | onClick: onRotateLeft,
119 | type: "rotateLeft",
120 | },
121 | {
122 | Icon: RotateRight,
123 | onClick: onRotateRight,
124 | type: "rotateRight",
125 | },
126 | ];
127 |
128 | // 处理旋转后、宽高补位
129 | useEffect(() => {
130 | if (rotate) {
131 | fixPosition();
132 | }
133 | }, [rotate]);
134 |
135 | function fixPosition() {
136 | if (!viewRef.current) return;
137 | const width = viewRef.current.offsetWidth * scale;
138 | const height = viewRef.current.offsetHeight * scale;
139 | const { left: imgLeft, top: imgTop } =
140 | viewRef.current.getBoundingClientRect();
141 | const { left: wrapLeft, top: wrapTop } =
142 | viewContainerRef.current?.getBoundingClientRect() || {
143 | left: 0,
144 | top: 0,
145 | };
146 |
147 | const isRotate = rotate % 180 !== 0;
148 | const fixState = getFixScaleEleTransPosition(
149 | isRotate ? height : width,
150 | isRotate ? width : height,
151 | imgLeft - wrapLeft,
152 | imgTop - wrapTop
153 | ) || { x: 0, y: 0 };
154 | if (fixState) {
155 | setPosition({ ...fixState });
156 | }
157 | }
158 | const onMouseUp = () => {
159 | if (isMoving) {
160 | setMoving(false);
161 | // fixPosition();
162 | }
163 | };
164 | const onMouseDown = (event: any) => {
165 | event.preventDefault();
166 | // Without this mask close will abnormal
167 | event.stopPropagation();
168 | originPositionRef.current.deltaX = event.clientX - position.x;
169 | originPositionRef.current.deltaY = event.clientY - position.y;
170 | originPositionRef.current.originX = position.x;
171 | originPositionRef.current.originY = position.y;
172 | setMoving(true);
173 | };
174 | const onMouseMove = (event: any) => {
175 | if (isMoving) {
176 | setPosition({
177 | x: event.clientX - originPositionRef.current.deltaX,
178 | y: event.clientY - originPositionRef.current.deltaY,
179 | });
180 | }
181 | };
182 |
183 | /**
184 | * 处理滚动事件
185 | */
186 | const [wheelNum, setWheelNum] = useState(0);
187 | useThrottleEffect(
188 | () => {
189 | if (!wheelNum) return;
190 | if (wheelNum > 1) {
191 | onZoomIn();
192 | setWheelNum(0);
193 | return;
194 | }
195 | if (wheelNum < -1) {
196 | onZoomOut();
197 | setWheelNum(0);
198 | }
199 | },
200 | [wheelNum],
201 | {
202 | wait: 40,
203 | }
204 | );
205 | const onWheel = (event: WheelEvent) => {
206 | // event.preventDefault();
207 | // deltaY < 100 区分笔记本触摸板滑动
208 | if (event.ctrlKey || Math.abs(event.deltaY) < 100) return;
209 | const direct = event.deltaY > 0 ? "down" : "up";
210 | setWheelNum((num) => {
211 | let curNum = num;
212 | if (direct === "up") {
213 | curNum += 1;
214 | } else {
215 | curNum -= 1;
216 | }
217 | return curNum;
218 | });
219 | };
220 |
221 | useEffect(() => {
222 | viewContainerRef.current?.addEventListener("mouseup", onMouseUp, false);
223 | viewContainerRef.current?.addEventListener("mousemove", onMouseMove, false);
224 |
225 | return () => {
226 | viewContainerRef.current?.removeEventListener(
227 | "mouseup",
228 | onMouseUp,
229 | false
230 | );
231 |
232 | viewContainerRef.current?.removeEventListener(
233 | "mousemove",
234 | onMouseMove,
235 | false
236 | );
237 | };
238 | }, [isMoving]);
239 |
240 | return {
241 | viewRef,
242 | viewContainerRef,
243 | tools,
244 | scale,
245 | rotate,
246 | position,
247 | onMouseDown,
248 | onWheel,
249 | resizeScale,
250 | };
251 | };
252 |
253 | function fixPoint(
254 | key: "x" | "y",
255 | start: number,
256 | width: number,
257 | clientWidth: number
258 | ) {
259 | const startAddWidth = start + width;
260 | const offsetStart = (width - clientWidth) / 2;
261 |
262 | if (width > clientWidth) {
263 | if (start > 0) {
264 | return {
265 | [key]: offsetStart,
266 | };
267 | }
268 | if (start < 0 && startAddWidth < clientWidth) {
269 | return {
270 | [key]: -offsetStart,
271 | };
272 | }
273 | } else if (start < 0 || startAddWidth > clientWidth) {
274 | return {
275 | [key]: start < 0 ? offsetStart : -offsetStart,
276 | };
277 | }
278 | return {};
279 | }
280 |
281 | /**
282 | * Fix positon x,y point when
283 | *
284 | * Ele width && height < client
285 | * - Back origin
286 | *
287 | * - Ele width | height > clientWidth | clientHeight
288 | * - left | top > 0 -> Back 0
289 | * - left | top + width | height < clientWidth | clientHeight -> Back left | top + width | height === clientWidth | clientHeight
290 | *
291 | * Regardless of other
292 | */
293 | export function getFixScaleEleTransPosition(
294 | width: number,
295 | height: number,
296 | left: number,
297 | top: number
298 | ): null | { x: number; y: number } {
299 | // const { width: clientWidth, height: clientHeight } = getClientSize();
300 | let fixPos: any = null;
301 | if (!document.querySelector("#imgContainer")) {
302 | return fixPos;
303 | }
304 | const { clientWidth, clientHeight } = document.querySelector(
305 | "#imgContainer"
306 | ) as Element;
307 |
308 | if (width <= clientWidth && height <= clientHeight) {
309 | fixPos = {
310 | x: 0,
311 | y: 0,
312 | };
313 | } else if (width > clientWidth || height > clientHeight) {
314 | fixPos = {
315 | ...fixPoint("x", left, width, clientWidth),
316 | ...fixPoint("y", top, height, clientHeight),
317 | };
318 | }
319 |
320 | return fixPos;
321 | }
322 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | --current-active-id: none;
15 | }
16 |
17 | a {
18 | font-weight: 500;
19 | color: #646cff;
20 | text-decoration: inherit;
21 | }
22 | a:hover {
23 | color: #535bf2;
24 | }
25 |
26 | body {
27 | margin: 0;
28 | display: flex;
29 | place-items: center;
30 | min-width: 320px;
31 | min-height: 100vh;
32 | }
33 |
34 | h1 {
35 | font-size: 3.2em;
36 | line-height: 1.1;
37 | }
38 |
39 | button {
40 | border-radius: 8px;
41 | border: 1px solid transparent;
42 | padding: 0.6em 1.2em;
43 | font-size: 1em;
44 | font-weight: 500;
45 | font-family: inherit;
46 | background-color: #1a1a1a;
47 | cursor: pointer;
48 | transition: border-color 0.25s;
49 | }
50 | button:hover {
51 | border-color: #646cff;
52 | }
53 | button:focus,
54 | button:focus-visible {
55 | outline: 4px auto -webkit-focus-ring-color;
56 | }
57 |
58 | @media (prefers-color-scheme: light) {
59 | :root {
60 | color: #213547;
61 | background-color: #ffffff;
62 | }
63 | a:hover {
64 | color: #747bff;
65 | }
66 | button {
67 | background-color: #f9f9f9;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // src/index.ts
2 | export { default as FilePreview } from "./components/FilePreview";
3 | export { default as ResultView } from "./components/ResultView";
4 | export { default as MarkLayer } from "./components/MarkLayer";
5 | export { default as JsonView } from "./components/JsonView";
6 |
7 | export { useContentLinkage } from "./hooks/useContentLinkage";
8 | export { usePDFMarkLayer } from "./hooks/usePDFMarkLayer";
9 | export { usePreviewTool } from "./hooks/usePreviewTool";
10 |
11 | // 导出类型定义
12 | export type {
13 | IRectItem,
14 | IPageItem,
15 | IResultListItem,
16 | IFieldItem,
17 | } from "./types/keyVal";
18 |
19 | export type { PDFSrc } from "./components/FilePreview/PDFViewer/types";
20 | export type { ToolbarOptions } from "./hooks/usePreviewTool";
21 | export type { PreviewToolItem } from "./hooks/usePreviewTool";
22 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | );
12 |
--------------------------------------------------------------------------------
/src/types/common.ts:
--------------------------------------------------------------------------------
1 | export type TypedArray =
2 | | Int8Array
3 | | Uint8Array
4 | | Uint8ClampedArray
5 | | Int16Array
6 | | Uint16Array
7 | | Int32Array
8 | | Uint32Array
9 | | Float32Array
10 | | Float64Array;
11 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.css";
2 | declare module "*.less" {
3 | const classes: { [key: string]: string };
4 | export default classes;
5 | }
6 | declare module "*.png";
7 | declare module "*.jpg";
8 | declare module "*.jpeg";
9 | declare module "rc-util*";
10 | declare module "*.xlsx";
11 | declare module "*.svg" {
12 | export function ReactComponent(
13 | props: React.SVGProps
14 | ): React.ReactElement;
15 | const url: string;
16 | export default url;
17 | }
18 |
19 | declare interface Window {
20 | [key: string]: any;
21 | pdfjsLib?: any;
22 | pdfjsWorker?: any;
23 | }
24 |
--------------------------------------------------------------------------------
/src/types/keyVal.ts:
--------------------------------------------------------------------------------
1 | export type IOriginResult = {
2 | pages: IOriginPageItem[];
3 | };
4 |
5 | export interface IOriginPageItem {
6 | duration: number;
7 | page_number: number;
8 | ppi: number;
9 | result: {
10 | width: number;
11 | height: number;
12 | object_list?: IOriginResultListItem[];
13 | rotated_image_width?: number;
14 | };
15 | }
16 |
17 | export interface IOriginResultListItem {
18 | image_angle: number;
19 | class: string;
20 | item_list?: IOriginFieldItem[];
21 | flight_data_list?: IOriginFieldItem[][];
22 | product_list?: IOriginFieldItem[][];
23 | transport_list?: IOriginFieldItem[][];
24 | stamp_list?: IOriginFieldItem[][];
25 | qr_code_list?: IOriginFieldItem[][];
26 | kind: string;
27 | kind_description: string;
28 | position: number[];
29 | rotated_image_height: number;
30 | rotated_image_width: number;
31 | type: string;
32 | type_description: string;
33 | page_id?: number;
34 | }
35 | export interface IOriginFieldItem {
36 | key: string;
37 | type?: string;
38 | value: string;
39 | description: string;
40 | position: number[];
41 | }
42 |
43 | export interface IFieldItem extends IOriginFieldItem {
44 | uid: string;
45 | parent_uid?: string;
46 | }
47 |
48 | export interface IRectItem {
49 | [key: string]: any;
50 | key?: string;
51 | type?: string;
52 | rect_type?: string;
53 | uid: string;
54 | parent_uid?: string;
55 | content_id: string;
56 | parent_id?: string;
57 | position: number[];
58 | angle?: number;
59 | render_text?: string;
60 | }
61 |
62 | export interface IResultListItem extends IRectItem {
63 | type: string;
64 | description: string;
65 | no: number;
66 | list: IFieldItem[];
67 | flightList: IFieldItem[][];
68 | page_id?: number;
69 | }
70 |
71 | export interface IPageItem {
72 | page_number: number;
73 | duration: number;
74 | ppi: number;
75 | width: number;
76 | height: number;
77 | angle?: number;
78 | }
79 |
--------------------------------------------------------------------------------
/src/utils/browser.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Browser list
3 | * google chrome: 65
4 | * QQBrowser: 10
5 | * Edge: 44
6 | * 360Browser: se
7 | * IE: 11
8 | */
9 |
10 | // 查看各个浏览器agent【https://tool.ip138.com/useragent/】
11 | const userAgent = navigator.userAgent || window.navigator.userAgent;
12 |
13 | function getVersion(browserAgent: string, keywords: string) {
14 | const reg = new RegExp(`(${keywords}\/)[0-9]+`);
15 | const version = browserAgent.match(reg);
16 | if (version?.length) {
17 | return version[0]?.replace(new RegExp(`${keywords}\/`), "");
18 | }
19 | return 100;
20 | }
21 |
22 | const BrowserCompatible = (agent?: string) => {
23 | const browserAgent = agent || userAgent;
24 | if (!browserAgent) return true;
25 |
26 | // 判断是否IE<11浏览器
27 | const isIE =
28 | browserAgent.indexOf("compatible") > -1 &&
29 | browserAgent.indexOf("MSIE") > -1;
30 | // 判断是否IE的Edge浏览器 [百科详情 - https://baike.baidu.com/item/Microsoft%20Edge#8]
31 | // 判断是否是旧版内核Edge
32 | const isEdge =
33 | browserAgent.indexOf("Edge") > -1 || browserAgent.indexOf("Edg") > -1;
34 | // 判断是否是QQ浏览器10版本以上
35 | const isQQBrowser = browserAgent.indexOf("QQBrowser") > -1;
36 | // 判断是否是360极速版本 / 360浏览器不区分版本
37 | const is360Browser = browserAgent.indexOf("360SE") > -1;
38 | // google
39 | const isChromeBrowser =
40 | browserAgent.indexOf("Chrome") > -1 &&
41 | browserAgent.indexOf("AppleWebKit") > -1;
42 |
43 | if (isIE) {
44 | return false;
45 | }
46 | if (isEdge) {
47 | if (browserAgent.indexOf("Edge") > -1) {
48 | const version = getVersion(browserAgent, "Edge");
49 | return +version >= 44;
50 | }
51 | if (browserAgent.indexOf("Edg") > -1) {
52 | const version = getVersion(browserAgent, "Edg");
53 | return +version >= 44;
54 | }
55 | }
56 | if (isChromeBrowser) {
57 | const version = getVersion(browserAgent, "Chrome");
58 | return +version >= 65;
59 | }
60 | if (isQQBrowser) {
61 | const version = getVersion(browserAgent, "QQBrowser");
62 | return +version >= 10;
63 | }
64 | if (is360Browser) {
65 | if (browserAgent.indexOf("360SEE") > -1) {
66 | return true;
67 | }
68 | return false;
69 | }
70 | return true;
71 | };
72 |
73 | export const checkBrowser = () => {
74 | const status = BrowserCompatible();
75 | const { location } = window;
76 | if (!status && location.pathname !== "/") {
77 | // ie 中没有window.location.origin
78 | window.location.replace(
79 | `${window.location.protocol}//${window.location.host}/`
80 | );
81 | return true;
82 | }
83 | };
84 |
85 | /**
86 | * 判断是否Mac OS
87 | */
88 | export const isMacOS = () => {
89 | if (!userAgent) return false;
90 | return /macintosh|mac os x/i.test(navigator.userAgent);
91 | };
92 |
93 | /**
94 | * 判断是否IE<11浏览器
95 | */
96 | export const isLessIE11 = userAgent
97 | ? userAgent.toLowerCase().match(/rv:([\d.]+)\) like gecko/)
98 | : true;
99 | export const isIEBrowser = userAgent
100 | ? userAgent.indexOf("rv:11.0") > -1 || userAgent.indexOf("MSIE") > -1
101 | : false;
102 |
103 | // 判断是否为移动端设备的函数
104 | export const isMobileDevice = (userAgent: string) => {
105 | return /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop|Mobile/i.test(
106 | userAgent
107 | );
108 | };
109 |
110 | export default BrowserCompatible;
111 |
--------------------------------------------------------------------------------
/src/utils/debounce.ts:
--------------------------------------------------------------------------------
1 | interface DebounceOptions {
2 | /**
3 | * 是否在延迟开始前调用
4 | * @default false
5 | */
6 | leading?: boolean;
7 | /**
8 | * 是否在延迟结束后调用
9 | * @default true
10 | */
11 | trailing?: boolean;
12 | /**
13 | * 最大等待时间 (毫秒)
14 | */
15 | maxWait?: number;
16 | }
17 |
18 | export interface Cancelable {
19 | /**
20 | * 取消当前延迟调用
21 | */
22 | cancel: () => void;
23 | /**
24 | * 立即执行并取消后续调用
25 | */
26 | flush: () => void;
27 | }
28 |
29 | export function debounce any>(
30 | func: T,
31 | wait: number = 0,
32 | options: DebounceOptions = {}
33 | ): T & Cancelable {
34 | type FuncParams = Parameters;
35 | type FuncReturn = ReturnType;
36 |
37 | let lastArgs: FuncParams | null = null;
38 | let lastThis: any;
39 | let result: FuncReturn;
40 | let timerId: ReturnType | null = null;
41 | let lastCallTime = 0;
42 | let lastInvokeTime = 0;
43 |
44 | const { leading = false, trailing = true, maxWait } = options;
45 | const maxing = typeof maxWait === "number";
46 |
47 | const invokeFunc = (time: number): FuncReturn => {
48 | const args = lastArgs!;
49 | const thisArg = lastThis;
50 |
51 | lastArgs = lastThis = null;
52 | lastInvokeTime = time;
53 | result = func.apply(thisArg, args);
54 | return result;
55 | };
56 |
57 | const remainingWait = (time: number): number => {
58 | const timeSinceLastCall = time - lastCallTime;
59 | const timeSinceLastInvoke = time - lastInvokeTime;
60 |
61 | const timeWaiting = wait - timeSinceLastCall;
62 |
63 | return maxing
64 | ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
65 | : timeWaiting;
66 | };
67 |
68 | const shouldInvoke = (time: number): boolean => {
69 | const timeSinceLastCall = time - lastCallTime;
70 | const timeSinceLastInvoke = time - lastInvokeTime;
71 |
72 | return (
73 | lastCallTime === 0 ||
74 | timeSinceLastCall >= wait ||
75 | timeSinceLastCall < 0 ||
76 | (maxing && timeSinceLastInvoke >= maxWait)
77 | );
78 | };
79 |
80 | const trailingEdge = (time: number): FuncReturn => {
81 | timerId = null;
82 | if (trailing && lastArgs) {
83 | return invokeFunc(time);
84 | }
85 | lastArgs = lastThis = null;
86 | return result;
87 | };
88 |
89 | const timerExpired = () => {
90 | const now = Date.now();
91 | if (shouldInvoke(now)) {
92 | return trailingEdge(now);
93 | }
94 | timerId = setTimeout(timerExpired, remainingWait(now));
95 | };
96 |
97 | const leadingEdge = (time: number): FuncReturn => {
98 | lastInvokeTime = time;
99 | timerId = setTimeout(timerExpired, wait);
100 | return leading ? invokeFunc(time) : result;
101 | };
102 |
103 | const debounced = function (this: any, ...args: FuncParams): FuncReturn {
104 | const now = Date.now();
105 | const isInvoking = shouldInvoke(now);
106 |
107 | lastArgs = args;
108 | lastThis = this;
109 | lastCallTime = now;
110 |
111 | if (isInvoking) {
112 | if (!timerId) {
113 | return leadingEdge(lastCallTime);
114 | }
115 | if (maxing) {
116 | timerId = setTimeout(timerExpired, wait);
117 | return invokeFunc(lastCallTime);
118 | }
119 | }
120 |
121 | if (!timerId) {
122 | timerId = setTimeout(timerExpired, wait);
123 | }
124 |
125 | return result;
126 | } as T & Cancelable;
127 |
128 | debounced.cancel = () => {
129 | if (timerId) {
130 | clearTimeout(timerId);
131 | timerId = null;
132 | }
133 | lastInvokeTime = 0;
134 | lastCallTime = 0;
135 | lastArgs = lastThis = null;
136 | };
137 |
138 | debounced.flush = () => {
139 | return timerId ? trailingEdge(Date.now()) : result;
140 | };
141 |
142 | return debounced;
143 | }
144 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | export function isElementInContainerViewport(
2 | el: HTMLElement | SVGPolygonElement,
3 | container: HTMLElement,
4 | offset: number = 0
5 | ): boolean {
6 | const elRect = el.getBoundingClientRect();
7 | const containerRect = container.getBoundingClientRect();
8 |
9 | // 计算垂直方向的重叠区域高度
10 | const overlapTop = Math.max(elRect.top, containerRect.top);
11 | const overlapBottom = Math.min(elRect.bottom, containerRect.bottom);
12 | const overlapHeight = overlapBottom - overlapTop;
13 | // 如果重叠高度 ≥ offset,返回 true
14 | return overlapHeight > offset;
15 | }
16 |
17 | export function scrollIntoViewIfNeeded(
18 | el?: HTMLElement | SVGPolygonElement,
19 | container?: HTMLElement,
20 | scrollArgs?: boolean | ScrollIntoViewOptions,
21 | offset = 0
22 | ) {
23 | if (!el || !container) {
24 | return;
25 | }
26 | if (isElementInContainerViewport(el, container, offset || 0)) {
27 | return;
28 | }
29 | el.scrollIntoView(scrollArgs);
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/object.ts:
--------------------------------------------------------------------------------
1 | export function ensureArray(data: any) {
2 | return (Array.isArray(data) ? data : []) as T[];
3 | }
4 |
5 | export const sortList = (list: any[]) => {
6 | return list
7 | .map((item) => ({
8 | ...item,
9 | value: [null, undefined].includes(item.value) ? "" : String(item.value),
10 | }))
11 | .sort((a, b) => {
12 | if (a.value && !b.value) {
13 | return -1;
14 | } else if (!a.value && b.value) {
15 | return 1;
16 | }
17 | return 0;
18 | });
19 | };
20 |
21 | export const isBoolean = (value: any) => value === true || value === false;
22 |
--------------------------------------------------------------------------------
/src/utils/throttle.ts:
--------------------------------------------------------------------------------
1 | import { Cancelable, debounce } from "./debounce";
2 |
3 | interface ThrottleOptions {
4 | /**
5 | * 是否在节流开始前调用
6 | * @default true
7 | */
8 | leading?: boolean;
9 | /**
10 | * 是否在节流结束后调用
11 | * @default true
12 | */
13 | trailing?: boolean;
14 | /**
15 | * 最大等待时间 (毫秒)
16 | */
17 | maxWait?: number;
18 | }
19 |
20 | export function throttle any>(
21 | func: T,
22 | wait: number = 0,
23 | options: ThrottleOptions = {}
24 | ): T & Cancelable {
25 | type FuncParams = Parameters;
26 | type FuncReturn = ReturnType;
27 |
28 | let lastArgs: FuncParams | null = null;
29 | let lastThis: any;
30 | let result: FuncReturn;
31 |
32 | const { leading = true, trailing = true, maxWait } = options;
33 | const throttled = debounce(func, wait, {
34 | leading,
35 | trailing,
36 | maxWait: typeof maxWait === "number" ? Math.max(maxWait, wait) : undefined,
37 | }) as T & Cancelable;
38 |
39 | // 覆盖 flush 方法类型
40 | const originalFlush = throttled.flush;
41 | throttled.flush = (() => {
42 | const flushed = originalFlush();
43 | lastArgs = lastThis = null;
44 | return flushed;
45 | }) as typeof originalFlush;
46 |
47 | return throttled;
48 | }
49 |
--------------------------------------------------------------------------------
/src/utils/transformers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IOriginPageItem,
3 | IOriginResult,
4 | IOriginResultListItem,
5 | IOriginFieldItem,
6 | IFieldItem,
7 | IResultListItem,
8 | IRectItem,
9 | } from "../types/keyVal";
10 | import { ensureArray, sortList } from "./object";
11 | import { generateUUID } from "./uuid";
12 |
13 | const convertFieldtem =
14 | (parentUid: string) =>
15 | ({
16 | value,
17 | description,
18 | key,
19 | position,
20 | type,
21 | }: IOriginFieldItem): IFieldItem => ({
22 | type,
23 | position: ensureArray(position),
24 | value,
25 | description: description || key || "",
26 | key,
27 | uid: generateUUID(),
28 | parent_uid: parentUid,
29 | });
30 |
31 | export const convertApiDataToResultList = (
32 | res: IOriginResult
33 | ): IResultListItem[] => {
34 | const objectList: IOriginResultListItem[] = res.pages.reduce(
35 | (pre: IOriginResultListItem[], cur, i: number) => [
36 | ...pre,
37 | ...(cur.result.object_list || []).map((item) => ({
38 | ...item,
39 | page_id: i,
40 | })),
41 | ],
42 | []
43 | );
44 |
45 | return objectList.map(
46 | (
47 | {
48 | position,
49 | type,
50 | type_description,
51 | item_list = [],
52 | flight_data_list,
53 | product_list,
54 | transport_list,
55 | stamp_list,
56 | qr_code_list,
57 | page_id = 0,
58 | },
59 | idx
60 | ) => {
61 | const tableData =
62 | product_list || flight_data_list || transport_list || [];
63 | const otherList = [
64 | ...ensureArray(stamp_list).map((list) =>
65 | ensureArray(list).map((item) => ({
66 | ...item,
67 | type: "stamp",
68 | }))
69 | ),
70 | ...ensureArray(qr_code_list).map((list) =>
71 | ensureArray(list).map((item) => ({
72 | ...item,
73 | type: "image",
74 | }))
75 | ),
76 | ];
77 | const typeItem = ensureArray(item_list).find(
78 | (item) => item.key === "invoice_type"
79 | );
80 | const parentUid = generateUUID();
81 | return {
82 | uid: parentUid,
83 | position: ensureArray(position).slice(0, 8),
84 | type,
85 | no: idx + 1,
86 | description: typeItem?.value || type_description,
87 | list: sortList(
88 | ensureArray(item_list).map(
89 | convertFieldtem(parentUid)
90 | )
91 | ),
92 | flightList: [
93 | ...tableData.map((item) =>
94 | sortList(item.map(convertFieldtem(parentUid)))
95 | ),
96 | ...otherList.map((item) =>
97 | sortList(item.map(convertFieldtem(parentUid)))
98 | ),
99 | ],
100 | page_id: page_id + 1,
101 | } as IResultListItem;
102 | }
103 | );
104 | };
105 |
106 | export const transformKeyValApiResultToView = (result: IOriginResult) => {
107 | const resultList = convertApiDataToResultList(result);
108 | const rects = resultList.reduce((pre: Record[][], item) => {
109 | if (typeof item.page_id === "number") {
110 | const pageIndex = item.page_id - 1;
111 | if (!pre[pageIndex]) {
112 | pre[pageIndex] = [];
113 | }
114 |
115 | if (Array.isArray(item.list)) {
116 | for (const row of item.list) {
117 | pre[pageIndex].push({
118 | content_id: row.uid,
119 | position: row.position,
120 | parent_id: item.uid,
121 | });
122 | }
123 | }
124 | let preItem;
125 | if (Array.isArray(item.flightList)) {
126 | for (const rowList of item.flightList) {
127 | for (const row of rowList) {
128 | // 印章多个字段共用一个坐标框
129 | if (row.type === "stamp") {
130 | if (
131 | preItem?.type === row.type &&
132 | preItem.position?.join() === row.position?.join()
133 | ) {
134 | continue;
135 | }
136 | }
137 | preItem = { ...row };
138 | pre[pageIndex].push({
139 | content_id: row.uid,
140 | position: row.position,
141 | type: row.type,
142 | parent_id: item.uid,
143 | });
144 | }
145 | }
146 | }
147 |
148 | pre[pageIndex].push({
149 | content_id: item.uid,
150 | position: item.position,
151 | type: "category",
152 | render_text: item.no,
153 | });
154 | }
155 |
156 | return pre;
157 | }, []);
158 | const finalRects = rects.map((rect) =>
159 | rect
160 | .map(
161 | (item, i) =>
162 | ({
163 | ...item,
164 | uid: item.content_id,
165 | content_id: item.content_id,
166 | position: item.position,
167 | type: item.type,
168 | rect_type: item.rect_type,
169 | angle: item.angle || 0,
170 | renderText: item.render_text,
171 | sort: ["category"].includes(item.type as string) ? i - 10000 : i,
172 | parent_uid: item.parent_id,
173 | } as IRectItem)
174 | )
175 | .sort((a, b) => a.sort - b.sort)
176 | );
177 | const pages = ensureArray(result?.pages).map((item) => ({
178 | ...item,
179 | width: item.result.width,
180 | height: item.result.height,
181 | }));
182 | return { result: resultList, rects: finalRects, pages };
183 | };
184 |
--------------------------------------------------------------------------------
/src/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | export const generateUUID = (): string => {
2 | let dateData = new Date().getUTCDate();
3 | const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
4 | /[xy]/g,
5 | (c: string) => {
6 | const randomData = (dateData + Math.random() * 16) % 16 | 0;
7 | dateData = Math.floor(dateData / 16);
8 | return (c === "x" ? randomData : (randomData & 0x3) | 0x8).toString(16);
9 | }
10 | );
11 | return uuid;
12 | };
13 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 | "noImplicitAny": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "isolatedModules": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true,
25 |
26 | "composite": true
27 | },
28 | "include": ["src"],
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "moduleResolution": "bundler",
9 | "allowImportingTsExtensions": false,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "jsx": "react-jsx",
13 | "strict": true,
14 | "noUnusedLocals": false,
15 | "noUnusedParameters": true,
16 | "noFallthroughCasesInSwitch": true,
17 | "declaration": true,
18 | "declarationDir": "./dist",
19 | "emitDeclarationOnly": true,
20 | "outDir": "./dist",
21 | "rootDir": "./src"
22 | },
23 | "include": ["src/**/*"],
24 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"],
25 | "references": [{ "path": "./tsconfig.node.json" }]
26 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "declaration": true,
15 | "declarationDir": "dist",
16 | "emitDeclarationOnly": true,
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 |
25 | "composite": true
26 | },
27 | "include": ["vite.config.ts"]
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/app.tsx","./src/index.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/filepreview/index.tsx","./src/components/filepreview/types.ts","./src/components/filepreview/imagesviewer/index.tsx","./src/components/filepreview/pdfviewer/index.tsx","./src/components/filepreview/pdfviewer/types.ts","./src/components/filepreview/pagination/index.tsx","./src/components/filepreview/toolbar/index.tsx","./src/components/jsonview/index.tsx","./src/components/loading/index.tsx","./src/components/marklayer/helpers.ts","./src/components/marklayer/index.tsx","./src/components/marklayer/svgrect/text.tsx","./src/components/marklayer/svgrect/index.tsx","./src/components/radiogroup/index.tsx","./src/components/resultview/footer.tsx","./src/components/resultview/header.tsx","./src/components/resultview/keyvaluelist.tsx","./src/components/resultview/keyvaluetable.tsx","./src/components/resultview/index.tsx","./src/examples/imageexample.tsx","./src/examples/pdfexample.tsx","./src/examples/data.ts","./src/hooks/usecontentlinkage.ts","./src/hooks/useframesetstate.ts","./src/hooks/useloadpdflib.ts","./src/hooks/usemarktool.ts","./src/hooks/usepdfmarklayer.ts","./src/hooks/usepreviewtool.tsx","./src/types/common.ts","./src/types/global.d.ts","./src/types/keyval.ts","./src/utils/browser.ts","./src/utils/debounce.ts","./src/utils/dom.ts","./src/utils/object.ts","./src/utils/throttle.ts","./src/utils/transformers.ts","./src/utils/uuid.ts"],"version":"5.7.3"}
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import svgr from "vite-plugin-svgr";
4 | import { resolve } from 'path';
5 | import dts from 'vite-plugin-dts';
6 |
7 | // https://vite.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | react(),
11 | svgr(),
12 | dts({
13 | include: ['src/**/*'],
14 | outDir: 'dist',
15 | rollupTypes: true
16 | })
17 | ],
18 | build: {
19 | lib: {
20 | entry: resolve(__dirname, 'src/index.ts'),
21 | name: 'TextinOcrFrontend',
22 | fileName: (format) => `index.${format === 'es' ? 'mjs' : 'js'}`
23 | },
24 | rollupOptions: {
25 | external: ['react', 'react-dom'],
26 | output: {
27 | globals: {
28 | react: 'React',
29 | 'react-dom': 'ReactDOM'
30 | }
31 | }
32 | }
33 | }
34 | });
35 |
--------------------------------------------------------------------------------