修复背景色不正确问题;优化位置信息; 大小改回 pt 提升打印准确度

This commit is contained in:
wuduoyi 2023-04-17 17:41:46 +08:00
parent 6b749b8b53
commit 7409ad6df1
27 changed files with 75371 additions and 79 deletions

View File

@ -10,20 +10,7 @@ order: 23
> 2.9.0 及以上版本
用于渲染 office 文档,目前只支持 docx 格式,通过前端转成 HTML 的方式进行渲染,支持以下功能:
- 基础文本样式
- 表格及表格样式
- 内嵌图片
- 列表
- 注音
- 链接
- 文本框
- 形状
- 数学公式(依赖 MathML需要比较新的浏览器或者试试 [polyfill](https://github.com/w3c/mathml-polyfills)
- 分页渲染
不支持的功能:艺术字、域、对象、目录
用于渲染 office 文档,目前只支持 docx 格式
## 基本用法
@ -42,6 +29,21 @@ order: 23
目前只支持 Word 文档,所以只有 word 的配置项,放在 `wordOptions`
Word 渲染支持以下功能:
- 基础文本样式
- 表格及表格样式
- 内嵌图片
- 列表
- 注音
- 链接
- 文本框
- 形状
- 数学公式(依赖 MathML需要比较新的浏览器或者试试 [polyfill](https://github.com/w3c/mathml-polyfills)
- 分页渲染
不支持的功能:艺术字、域、对象、目录
### word 渲染配置属性表
```json
@ -63,6 +65,7 @@ order: 23
| fontMapping | `object` | | 字体映射,是个键值对,用于替换文档中的字体 |
| forceLineHeight | `string` | | 设置段落行高,忽略文档中的设置 |
| enableVar | `boolean` | true | 是否开启变量替换功能 |
| printOptions | `object` | | 针对打印的特殊设置,可以覆盖其它所有设置项 |
#### 分页渲染
@ -84,14 +87,13 @@ order: 23
分页渲染的其它设置项
| 属性名 | 类型 | 默认值 | 说明 |
| ------------------ | --------- | --------- | ------------------------------------------------ |
| ------------------ | --------- | --------- | ------------------------------------------ | --- |
| page | `boolean` | false | 是否开启分页渲染 |
| pageMarginBottom | `number` | 20 | 页面上下间距 |
| pageBackground | `string` | '#FFF' | 页面内背景色 |
| pageShadow | `boolean` | true | 是否显示阴影 |
| pageWrap | `boolean` | true | 是否显示页面包裹,开启这个后才能设置包裹的背景色 |
| pageWrap | `boolean` | true | 是否显示页面包裹 |
| pageWrapBackground | `string` | '#ECECEC' | 是否显示页面包裹 |
| pageWrap | `boolean` | true | 是否显示页面包裹 | |
| pageWrapBackground | `string` | '#ECECEC' | 页面包裹的背景色 |
| zoom | `number` | | 缩放比例,取值 0-1 之间 |
| zoomFitWidth | `boolean` | false | 自适应宽度缩放,如果设置了 zoom 将不会生效 |
@ -303,6 +305,18 @@ order: 23
]
```
有个 `printOptions` 配置项可以用来自定义在打印时的配置,默认设置是:
```json
{
"page": true,
"pageWrap": false,
"pageShadow": false,
"pageMarginBottom": 0,
"pageWrapPadding": undefined
}
```
## 配合文件上传实现预览功能
配置和 `input-file` 相同的 `name` 即可
@ -331,7 +345,7 @@ order: 23
## 是否显示 loading
通过 `"loading": true` 配置显示 loading
通过 `"loading": true` 配置显示 loading,主要用于网络较慢的场景。
## 属性表

View File

@ -78,6 +78,14 @@ export default class OfficeViewer extends React.Component<
}
}
if (
JSON.stringify(prevProps.wordOptions) !==
JSON.stringify(props.wordOptions) ||
prevProps.display !== props.display
) {
this.renderWord();
}
// 这个变量替换只会更新变化的部分,所以性能还能接受
this.word?.updateVariable();
}
@ -147,6 +155,9 @@ export default class OfficeViewer extends React.Component<
if (display !== false) {
word.render(this.rootElement?.current!);
} else if (display === false && this.rootElement?.current) {
// 设置为 false 后清空
this.rootElement.current.innerHTML = '';
}
this.word = word;
@ -172,6 +183,9 @@ export default class OfficeViewer extends React.Component<
});
if (display !== false) {
word.render(this.rootElement?.current!);
} else if (display === false && this.rootElement?.current) {
// 设置为 false 后清空
this.rootElement.current.innerHTML = '';
}
});
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -45,7 +45,6 @@ const fileLists = {
],
docx4j: [
'ArialUnicodeMS.docx',
'DOCPROP_builtin.docx',
'Symbols.docx',
'Word2007-fonts.docx',
'chart.docx',
@ -115,8 +114,7 @@ const data = {
};
const renderOptions = {
debug: true,
page,
zoomFitWidth: true
page
};
async function renderDocx(fileName: string) {

View File

@ -27,6 +27,7 @@ import {Note} from './openxml/word/Note';
import {parseFootnotes} from './parse/Footnotes';
import {parseEndnotes} from './parse/parseEndnotes';
import {renderNotes} from './render/renderNotes';
import {Section} from './openxml/word/Section';
/**
*
@ -145,6 +146,11 @@ export interface WordRenderOptions {
* zoom zoom ignoreWidth false
*/
zoomFitWidth?: boolean;
/**
*
*/
printOptions?: WordRenderOptions;
}
const defaultRenderOptions: WordRenderOptions = {
@ -288,6 +294,11 @@ export default class Word {
*/
breakPage = false;
/**
*
*/
currentSection: Section;
/**
*
*/
@ -634,10 +645,21 @@ export default class Word {
iframe.style.position = 'absolute';
iframe.style.top = '-10000px';
document.body.appendChild(iframe);
iframe.contentDocument?.write('<div id="print"></div>');
iframe.contentDocument?.write(
'<style>html, body {margin:0; padding:0}</style><div id="print"></div>'
);
await this.render(
iframe.contentDocument?.getElementById('print') as HTMLElement,
{page: false, pageWrap: false}
// 这些配置可以让打印还原度更高
{
page: true,
pageWrap: false,
pageShadow: false,
pageMarginBottom: 0,
pageWrapPadding: undefined,
zoom: 1,
...this.renderOptions.printOptions
}
);
setTimeout(function () {
iframe.focus();
@ -682,7 +704,7 @@ export default class Word {
root.classList.add(this.getClassPrefix());
if (renderOptions.page && renderOptions.pageWrap) {
root.classList.add(this.wrapClassName);
root.style.padding = `${renderOptions.pageWrapPadding || 0}px`;
root.style.padding = `${renderOptions.pageWrapPadding || 0}pt`;
root.style.background = renderOptions.pageWrapBackground || '#ECECEC';
}

View File

@ -61,9 +61,25 @@ export class WDocument {
break;
default:
console.log('unknown background', background);
break;
}
}
for (const child of background.children) {
const name = child.tagName;
switch (name) {
case 'v:background':
// vml 的背景色,不支持
break;
default:
console.log('unknown background', background);
break;
}
}
doc.documentBackground = documentBackground;
}
return doc;

View File

@ -23,9 +23,13 @@ export function shapeToSVG(
): SVGElement {
const svg = createSVGElement('svg');
// 边框有时候会超过
svg.setAttribute('style', 'overflow: visible');
svg.setAttribute('width', width.toString() + 'px');
svg.setAttribute('height', height.toString() + 'px');
// z-index 是因为后面可能会有文字,避免遮挡
svg.setAttribute(
'style',
'overflow: visible; position: absolute; z-index: -1'
);
svg.setAttribute('width', width.toString() + 'pt');
svg.setAttribute('height', height.toString() + 'pt');
// 变量值
const vars: Var = presetVal(width, height);

View File

@ -24,7 +24,9 @@ function parseBodyPr(element: Element, style: CSSStyle) {
const value = attribute.value;
switch (name) {
case 'numCol':
if (value !== '1') {
style['column-count'] = value;
}
break;
case 'vert':

View File

@ -74,7 +74,7 @@ export function parseShdColor(word: Word, element: Element) {
const val = getVal(element) as ST_Shd;
if (color === 'auto') {
color = '000000';
color = 'FFFFFF';
}
if (color.length === 6) {
@ -139,6 +139,10 @@ export function parseShdColor(word: Word, element: Element) {
* alpha ptc
*/
function colorPercent(color: string, percent: number): string {
// 白色取 alpha 没什么意义,转成黑色
if (color === 'FFFFFF') {
color = '000000';
}
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);

View File

@ -363,6 +363,10 @@ export function parsePr(word: Word, element: Element, type: 'r' | 'p' = 'p') {
// 目前还不支持 grid
break;
case 'w:topLinePunct':
// 没法支持
break;
case 'w:wordWrap':
// 不太确定这里是用 word-break 还是 overflow-wrap
if (getValBoolean(child)) {
@ -425,26 +429,26 @@ export function parsePr(word: Word, element: Element, type: 'r' | 'p' = 'p') {
case 'w:outline':
style['text-shadow'] =
'-1px -1px 0 #AAA, 1px -1px 0 #AAA, -1px 1px 0 #AAA, 1px 1px 0 #AAA';
'-1pt -1pt 0 #AAA, 1pt -1pt 0 #AAA, -1pt 1pt 0 #AAA, 1pt 1pt 0 #AAA';
break;
case 'w:shadown':
case 'w:imprint':
if (getValBoolean(child, true)) {
style['text-shadow'] = '1px 1px 2px rgba(0, 0, 0, 0.6)';
style['text-shadow'] = '1pt 1pt 2pt rgba(0, 0, 0, 0.6)';
}
break;
case 'w14:shadow':
const blurRad =
parseSize(child, 'w14:blurRad', LengthUsage.Emu) || '2px';
parseSize(child, 'w14:blurRad', LengthUsage.Emu) || '2pt';
// 其它结果算出来不像就先忽略了
let color = 'rgba(0, 0, 0, 0.6)';
const childColor = parseChildColor(word, child);
if (childColor) {
color = childColor;
}
style['text-shadow'] = `1px 1px ${blurRad} ${color}`;
style['text-shadow'] = `1pt 1pt ${blurRad} ${color}`;
break;
default:

View File

@ -2,18 +2,16 @@
* docxjs
*/
export type LengthType = 'px' | 'pt' | '%' | '';
export type LengthType = 'pt' | '%' | '';
export type LengthUsageType = {mul: number; unit: LengthType};
const ptToPx = 1.3333;
export const LengthUsage: Record<string, LengthUsageType> = {
Dxa: {mul: ptToPx * 0.05, unit: 'px'}, //twips
Emu: {mul: (ptToPx * 1) / 12700, unit: 'px'},
FontSize: {mul: ptToPx * 0.5, unit: 'px'},
Border: {mul: ptToPx * 0.125, unit: 'px'},
Point: {mul: ptToPx * 1, unit: 'px'},
Dxa: {mul: 0.05, unit: 'pt'}, //twips
Emu: {mul: 1 / 12700, unit: 'pt'},
FontSize: {mul: 0.5, unit: 'pt'},
Border: {mul: 0.125, unit: 'pt'},
Point: {mul: 1, unit: 'pt'},
Percent: {mul: 0.02, unit: '%'},
LineHeight: {mul: 1 / 240, unit: ''},
VmlEmu: {mul: 1 / 12700, unit: ''}

View File

@ -45,11 +45,13 @@ function appendToSection(
section: Section,
child: HTMLElement
) {
// 如果是第一个节点,即便超长也得写入,不然就会出现一个空 section
const isFirst = sectionEl.children.length === 0;
// 首先尝试写入
appendChild(sectionEl, child);
// 如果超出了就新建一个 section
if (createNewSection(word, sectionEnd, child)) {
if (!isFirst && createNewSection(word, sectionEnd, child)) {
const newChild = child.cloneNode(true) as HTMLElement;
removeChild(sectionEl, child);
let newSectionEl = renderSection(word, section, renderOptions);
@ -75,11 +77,11 @@ function getSectionEnd(section: Section, sectionEl: HTMLElement): SectionEnd {
const pageMargin = section.properties.pageMargin;
let bottom = sectionBound.top + sectionBound.height;
if (pageMargin?.bottom) {
bottom = bottom - parseInt(pageMargin.bottom.replace('px', ''), 10);
bottom = bottom - parseInt(pageMargin.bottom.replace('pt', ''), 10);
}
let right = sectionBound.left + sectionBound.width;
if (pageMargin?.right) {
right = right - parseInt(pageMargin.right.replace('px', ''), 10);
right = right - parseInt(pageMargin.right.replace('pt', ''), 10);
}
return {bottom, right};
}
@ -97,15 +99,15 @@ function getTransform(
if (renderOptions.zoomFitWidth && !renderOptions.ignoreWidth) {
const pageWidth = pageSize?.width;
if (rootWidth && pageWidth) {
let pageWidthNum = parseInt(pageWidth.replace('px', ''), 10);
let pageWidthNum = parseInt(pageWidth.replace('pt', ''), 10);
if (props.pageMargin) {
const pageMargin = props.pageMargin;
pageWidthNum += pageMargin.left
? parseInt(pageMargin.left.replace('px', ''), 10)
? parseInt(pageMargin.left.replace('pt', ''), 10)
: 0;
pageWidthNum += pageMargin.right
? parseInt(pageMargin.right.replace('px', ''), 10)
? parseInt(pageMargin.right.replace('pt', ''), 10)
: 0;
}
const zoomWidth = rootWidth / pageWidthNum;
@ -196,6 +198,7 @@ export default function renderBody(
let isLastSection = false;
for (const section of sections) {
zooms.push(getTransform(rootWidth, section, renderOptions));
word.currentSection = section;
let sectionEl = renderSection(word, section, renderOptions);
appendChild(bodyEl, sectionEl);
@ -207,7 +210,6 @@ export default function renderBody(
renderSectionInPage(
word,
bodyEl,
renderOptions,
sectionEl,
section,

View File

@ -8,6 +8,8 @@ import renderTable from './renderTable';
import {Table} from '../openxml/word/Table';
import {renderGeom} from './renderGeom';
import {renderCustGeom} from './renderCustGeom';
import {CSSStyle} from '../openxml/Style';
import parseSides from '../util/parseSides';
/**
*
@ -44,12 +46,52 @@ function renderPic(pic: Pic, word: Word, drawing: Drawing) {
return null;
}
/**
* padding html padding
* @param word
* @param style
*/
function fixAbsolutePosition(word: Word, style: CSSStyle) {
let paddingLeft = 0;
let paddingTop = 0;
const customPadding = word.renderOptions.padding;
if (customPadding) {
const {left, top} = parseSides(customPadding);
paddingLeft = parseInt(left || '0');
paddingTop = parseInt(top || '0');
} else {
const currentSection = word.currentSection;
if (currentSection) {
const pageMargin = currentSection.properties.pageMargin;
if (pageMargin) {
paddingLeft = parseInt(pageMargin.left || '0');
paddingTop = parseInt(pageMargin.top || '0');
}
}
}
const leftStyle = style.left;
if (leftStyle) {
style.left = `${
parseInt(String(leftStyle).replace('pt', ''), 10) + paddingLeft
}pt`;
}
const topStyle = style.top;
if (topStyle) {
style.top = `${
parseInt(String(topStyle).replace('pt', ''), 10) + paddingTop
}pt`;
}
}
/**
* picture
* http://officeopenxml.com/drwOverview.php
*
*/
export function renderDrawing(word: Word, drawing: Drawing): HTMLElement {
export function renderDrawing(
word: Word,
drawing: Drawing
): HTMLElement | null {
const container = document.createElement('div');
if (drawing.position === 'inline') {
@ -61,11 +103,16 @@ export function renderDrawing(word: Word, drawing: Drawing): HTMLElement {
appendChild(container, renderPic(drawing.pic, word, drawing));
}
if (!drawing.relativeFromParagraph) {
fixAbsolutePosition(word, drawing.containerStyle || {});
}
applyStyle(container, drawing.containerStyle);
if (drawing.wps) {
const wps = drawing.wps;
const spPr = wps.spPr;
applyStyle(container, wps.style);
if (spPr?.xfrm) {
@ -75,16 +122,16 @@ export function renderDrawing(word: Word, drawing: Drawing): HTMLElement {
container.style.height = ext.cy;
if (spPr.geom) {
const width = parseFloat(ext.cx.replace('px', ''));
const height = parseFloat(ext.cy.replace('px', ''));
const width = parseFloat(ext.cx.replace('pt', ''));
const height = parseFloat(ext.cy.replace('pt', ''));
appendChild(
container,
renderGeom(spPr.geom, spPr, width, height, wps.wpsStyle)
);
}
if (spPr.custGeom) {
const width = parseFloat(ext.cx.replace('px', ''));
const height = parseFloat(ext.cy.replace('px', ''));
const width = parseFloat(ext.cx.replace('pt', ''));
const height = parseFloat(ext.cy.replace('pt', ''));
appendChild(
container,
renderCustGeom(spPr.custGeom, spPr, width, height, wps.wpsStyle)
@ -106,5 +153,10 @@ export function renderDrawing(word: Word, drawing: Drawing): HTMLElement {
}
}
// 如果没内容就不渲染了,避免高度导致撑开父节点
if (container.children.length === 0) {
return null;
}
return container;
}

View File

@ -37,16 +37,35 @@ function renderNote(
}
}
/*
* 0 -1
*/
function hasNote(notes: Record<string, Note>) {
if (!notes) {
return false;
}
for (const id in notes) {
if (id !== '0' && id !== '-1') {
return true;
}
}
return false;
}
export function renderNotes(word: Word) {
const noteRoot = createElement('div');
for (const fId in word.footNotes || {}) {
if (hasNote(word.footNotes)) {
for (const fId in word.footNotes) {
renderNote(word, noteRoot, 'footnote', fId, word.footNotes[fId]);
}
}
if (hasNote(word.endNotes)) {
for (const fId in word.endNotes || {}) {
renderNote(word, noteRoot, 'endnote', fId, word.endNotes[fId]);
}
}
if (noteRoot.children.length) {
return noteRoot;

View File

@ -37,12 +37,6 @@ export default function renderParagraph(
appendChild(p, renderNumbering(p, word, properties.numPr));
}
if (properties.tabs) {
for (const tab of properties.tabs) {
appendChild(p, renderTab(word, tab));
}
}
let inFldChar = false;
for (const child of paragraph.children) {

View File

@ -16,13 +16,12 @@ export function renderSection(
sectionEl.style.position = 'relative';
if (renderOptions.page) {
sectionEl.style.overflow = 'hidden';
if (renderOptions.pageMarginBottom) {
sectionEl.style.marginBottom = renderOptions.pageMarginBottom + 'px';
sectionEl.style.marginBottom = renderOptions.pageMarginBottom + 'pt';
}
if (renderOptions.pageShadow) {
sectionEl.style.boxShadow = '0 0 8px rgba(0, 0, 0, 0.5)';
sectionEl.style.boxShadow = '0 0 8pt rgba(0, 0, 0, 0.5)';
}
if (renderOptions.pageBackground) {

View File

@ -2,6 +2,6 @@ import {createElement} from '../util/dom';
export function renderSeparator() {
const sep = createElement('hr');
sep.style.borderTop = '1px solid #bbb';
sep.style.borderTop = '1pt solid #bbb';
return sep;
}

View File

@ -5,15 +5,14 @@ import Word from '../Word';
/**
* tab
* tabs
* http://officeopenxml.com/WPtab.php
*/
export function renderTab(word: Word, tab: Tab) {
const tabElement = createElement('span');
tabElement.style.display = 'inline-block';
tabElement.style.width = tab.pos;
tabElement.innerHTML = '&nbsp;';
tabElement.innerHTML = '&emsp;';
if (tab.leader === 'dot') {
tabElement.style.borderBottom = '1px dotted';
tabElement.style.borderBottom = '1pt dotted';
}
return tabElement;
}

View File

@ -0,0 +1,43 @@
/**
* https://github.com/jednano/parse-css-sides/blob/master/src/index.ts
*/
export interface ISides {
top: string;
right: string;
bottom: string;
left: string;
important?: boolean;
}
export default function parseSides(value: string): ISides {
const sides = value.split(/\s+/);
const pos = sides.lastIndexOf('!important');
const important = pos !== -1;
if (important) {
sides.splice(pos, 1);
}
const numberOfSides = sides.length;
if (numberOfSides < 1 || numberOfSides > 4) {
throw new Error(`Cannot parse ${numberOfSides} sides`);
}
const [first, ...rest] = sides;
return createSides(first, ...rest);
function createSides(
top: string,
right: string = top,
bottom: string = top,
left: string = right
) {
return {
bottom,
left,
right,
top,
...(important ? {important} : {})
};
}
}

View File

@ -31,6 +31,13 @@
"amis-editor": ["./packages/amis-editor/src/index.tsx"]
}
},
"watchOptions": {
"watchFile": "useFsEvents",
"watchDirectory": "useFsEvents",
"fallbackPolling": "dynamicPriority",
"synchronousWatchDirectory": true,
"excludeDirectories": ["**/node_modules"]
},
"types": ["typePatches"],
"references": [],
"include": [