mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
This commit is contained in:
1 parent
2ea937b7be
commit
1c7f3c03e9
6 files changed
+189
-11
No files matched your search
@@ -785,6 +785,34 @@ button.outline-link:hover {
|
|||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: transparent;
|
||||||
|
opacity: 0.24;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-run > span {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
font: 100px/1 sans-serif;
|
||||||
|
white-space: pre;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
cursor: text;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-run > span::selection {
|
||||||
|
color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.page-link {
|
.page-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
@@ -833,6 +861,8 @@ button.outline-link:hover {
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-list {
|
.meta-list {
|
||||||
|
|||||||
+81
-4
@@ -50,6 +50,7 @@ const state = {
|
|||||||
continuous: false,
|
continuous: false,
|
||||||
renderAnnotations: true,
|
renderAnnotations: true,
|
||||||
pageCache: new Map(),
|
pageCache: new Map(),
|
||||||
|
selectedPages: new Set(),
|
||||||
pageInFlight: new Map(),
|
pageInFlight: new Map(),
|
||||||
pageRenderQueue: [],
|
pageRenderQueue: [],
|
||||||
pageRenderRunning: false,
|
pageRenderRunning: false,
|
||||||
@@ -194,6 +195,12 @@ el.fitButton.addEventListener("click", fitWidth);
|
|||||||
el.fitHeightButton.addEventListener("click", fitHeight);
|
el.fitHeightButton.addEventListener("click", fitHeight);
|
||||||
el.continuousButton.addEventListener("click", toggleContinuous);
|
el.continuousButton.addEventListener("click", toggleContinuous);
|
||||||
el.annotationButton.addEventListener("click", toggleAnnotations);
|
el.annotationButton.addEventListener("click", toggleAnnotations);
|
||||||
|
document.addEventListener("copy", copySelection);
|
||||||
|
document.addEventListener("selectionchange", () => {
|
||||||
|
for (const index of state.selectedPages) {
|
||||||
|
unmountPage(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
|
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
|
||||||
el.exportPageButton.addEventListener("click", exportCurrentPage);
|
el.exportPageButton.addEventListener("click", exportCurrentPage);
|
||||||
el.exportButton.addEventListener("click", exportPDF);
|
el.exportButton.addEventListener("click", exportPDF);
|
||||||
@@ -1471,6 +1478,7 @@ function renderPageFlow() {
|
|||||||
|
|
||||||
function resetPageFlow() {
|
function resetPageFlow() {
|
||||||
state.pageCache.clear();
|
state.pageCache.clear();
|
||||||
|
state.selectedPages.clear();
|
||||||
state.visiblePages.clear();
|
state.visiblePages.clear();
|
||||||
for (const task of state.pageInFlight.values()) {
|
for (const task of state.pageInFlight.values()) {
|
||||||
task.resolve(null);
|
task.resolve(null);
|
||||||
@@ -1509,13 +1517,11 @@ function flowPageObserver() {
|
|||||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||||
if (!entry.isIntersecting) {
|
if (!entry.isIntersecting) {
|
||||||
state.visiblePages.delete(index);
|
state.visiblePages.delete(index);
|
||||||
if (entry.target.classList.contains("rendered")) {
|
unmountPage(index);
|
||||||
entry.target.querySelector(".page-surface").replaceChildren();
|
|
||||||
entry.target.classList.remove("rendered");
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
state.visiblePages.add(index);
|
state.visiblePages.add(index);
|
||||||
|
state.selectedPages.delete(index);
|
||||||
renderFlowPage(index, { openSeq, priority: 4 });
|
renderFlowPage(index, { openSeq, priority: 4 });
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -1526,6 +1532,18 @@ function flowPageObserver() {
|
|||||||
return state.pageObserver;
|
return state.pageObserver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unmountPage(index) {
|
||||||
|
const shell = pageShell(index);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (selection.rangeCount && selection.getRangeAt(0).intersectsNode(shell)) {
|
||||||
|
state.selectedPages.add(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shell.querySelector(".page-surface").replaceChildren();
|
||||||
|
shell.classList.remove("rendered");
|
||||||
|
state.selectedPages.delete(index);
|
||||||
|
}
|
||||||
|
|
||||||
async function renderFlowPage(index, options = {}) {
|
async function renderFlowPage(index, options = {}) {
|
||||||
const openSeq = options.openSeq || state.openSeq;
|
const openSeq = options.openSeq || state.openSeq;
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
@@ -1638,6 +1656,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
|
|||||||
const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
|
const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
|
||||||
svg.classList.add("ofd-svg");
|
svg.classList.add("ofd-svg");
|
||||||
surface.replaceChildren(svg);
|
surface.replaceChildren(svg);
|
||||||
|
surface.append(createTextLayer(page.text));
|
||||||
for (const link of page.links) {
|
for (const link of page.links) {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
@@ -1668,6 +1687,64 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTextLayer(text) {
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.className = "text-layer";
|
||||||
|
const measure = document.createElement("canvas").getContext("2d");
|
||||||
|
measure.font = "100px sans-serif";
|
||||||
|
for (const run of text.runs || []) {
|
||||||
|
const line = document.createElement("span");
|
||||||
|
line.className = "text-run";
|
||||||
|
const chars = Array.from(run.text);
|
||||||
|
for (const item of run.spans || []) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = chars.slice(item.start, item.end).join("");
|
||||||
|
const width = measure.measureText(span.textContent).width || 1;
|
||||||
|
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
|
||||||
|
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
|
||||||
|
line.append(span);
|
||||||
|
}
|
||||||
|
layer.append(line);
|
||||||
|
}
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySelection(event) {
|
||||||
|
if (event.target.closest?.("input, textarea, [contenteditable]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (selection.isCollapsed || !selection.rangeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!el.svgHost.contains(range.startContainer) || !el.svgHost.contains(range.endContainer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
for (const run of el.svgHost.querySelectorAll(".text-run")) {
|
||||||
|
if (!range.intersectsNode(run)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const part = range.cloneRange();
|
||||||
|
if (!run.contains(part.startContainer)) {
|
||||||
|
part.setStart(run, 0);
|
||||||
|
}
|
||||||
|
if (!run.contains(part.endContainer)) {
|
||||||
|
part.setEnd(run, run.childNodes.length);
|
||||||
|
}
|
||||||
|
const text = part.cloneContents().textContent;
|
||||||
|
if (text) {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.clipboardData.setData("text/plain", lines.join("\n"));
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function markFlowPageError(index, err) {
|
function markFlowPageError(index, err) {
|
||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
if (!shell) {
|
if (!shell) {
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ func renderPage(args []js.Value) (any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
text, err := currentSession.PageText(args[0].Int())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
textData, err := json.Marshal(text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
links := make([]any, len(page.Links))
|
links := make([]any, len(page.Links))
|
||||||
for i, link := range page.Links {
|
for i, link := range page.Links {
|
||||||
links[i] = map[string]any{
|
links[i] = map[string]any{
|
||||||
@@ -182,6 +190,7 @@ func renderPage(args []js.Value) (any, error) {
|
|||||||
"height": page.Height,
|
"height": page.Height,
|
||||||
"svg": page.SVG,
|
"svg": page.SVG,
|
||||||
"links": links,
|
"links": links,
|
||||||
|
"text": js.Global().Get("JSON").Call("parse", string(textData)),
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -270,10 +270,10 @@ func (s *Session) SetFonts(fonts []FontFile) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchPage 搜索指定页面,复用当前会话的文字索引
|
// PageText 获取指定页面文字,复用当前会话的文字索引
|
||||||
// 入参: index 页面索引, query 搜索文字
|
// 入参: index 页面索引
|
||||||
// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息
|
// 返回: *ofdgo.PageText 页面文字, error 错误信息
|
||||||
func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) {
|
func (s *Session) PageText(index int) (*ofdgo.PageText, error) {
|
||||||
text := s.textCache[index]
|
text := s.textCache[index]
|
||||||
if text == nil {
|
if text == nil {
|
||||||
_, page, err := s.pageContent(index)
|
_, page, err := s.pageContent(index)
|
||||||
@@ -286,6 +286,17 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error)
|
|||||||
}
|
}
|
||||||
s.textCache[index] = text
|
s.textCache[index] = text
|
||||||
}
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchPage 搜索指定页面,复用当前会话的文字索引
|
||||||
|
// 入参: index 页面索引, query 搜索文字
|
||||||
|
// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息
|
||||||
|
func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) {
|
||||||
|
text, err := s.PageText(index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return text.Search(query), nil
|
return text.Search(query), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal)
|
face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal)
|
||||||
|
var metrics canvas.FontMetrics
|
||||||
|
if textRun != nil {
|
||||||
|
metrics = face.Metrics()
|
||||||
|
}
|
||||||
glyphTransforms := r.textObjectGlyphTransforms(fontID, obj)
|
glyphTransforms := r.textObjectGlyphTransforms(fontID, obj)
|
||||||
hasUnderline := strings.Contains(obj.Decoration, "Underline")
|
hasUnderline := strings.Contains(obj.Decoration, "Underline")
|
||||||
_, shadedFill := fillPaint.(canvas.Gradient)
|
_, shadedFill := fillPaint.(canvas.Gradient)
|
||||||
@@ -291,6 +295,11 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
for j := spans[i][0]; j < spans[i][1]; j++ {
|
for j := spans[i][0]; j < spans[i][1]; j++ {
|
||||||
textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box)
|
textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box)
|
||||||
}
|
}
|
||||||
|
bounds := canvas.Rect{Y0: -metrics.Descent, X1: glyphWidth, Y1: metrics.Ascent}
|
||||||
|
if !glyphPath.Empty() {
|
||||||
|
bounds = bounds.Add(glyphPath.Bounds())
|
||||||
|
}
|
||||||
|
textRun.addSpan(textPos+spans[i][0], textPos+spans[i][1], bounds, m.Scale(scaleX, 1), clipPath, pageH)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-3
@@ -31,9 +31,18 @@ type PageText struct {
|
|||||||
// TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米
|
// TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米
|
||||||
// Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域
|
// Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域
|
||||||
type TextRun struct {
|
type TextRun struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Boxes []Box `json:"boxes"`
|
Boxes []Box `json:"boxes"`
|
||||||
|
Spans []TextSpan `json:"spans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextSpan 可选文字区间,Start和End为Unicode字符偏移
|
||||||
|
// Matrix依次为a、b、c、d、e、f,将单位矩形映射到页面左上角为原点的毫米坐标
|
||||||
|
type TextSpan struct {
|
||||||
|
Start int `json:"start"`
|
||||||
|
End int `json:"end"`
|
||||||
|
Matrix [6]float64 `json:"matrix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间
|
// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间
|
||||||
@@ -176,3 +185,36 @@ func unionTextBox(a, b Box) Box {
|
|||||||
x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y)
|
x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y)
|
||||||
return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y}
|
return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addSpan 收集字形选择区域,同一原文区间的多个字形合并为一项
|
||||||
|
// 入参: start 起始字符, end 结束字符, bounds 字形布局区域, m 字形变换, clip 裁剪路径, pageH 页面高度
|
||||||
|
func (run *TextRun) addSpan(start, end int, bounds canvas.Rect, m canvas.Matrix, clip *canvas.Path, pageH float64) {
|
||||||
|
if m.Det() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clip != nil {
|
||||||
|
path := canvas.Rectangle(bounds.W(), bounds.H()).Translate(bounds.X0, bounds.Y0).Transform(m)
|
||||||
|
path = applyClipPath(path, clip)
|
||||||
|
if path.Empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bounds = path.Transform(m.Inv()).Bounds()
|
||||||
|
}
|
||||||
|
if bounds.W() < 0 || bounds.H() <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m = canvas.Matrix{{1, 0, 0}, {0, -1, pageH}}.Mul(m).Translate(bounds.X0, bounds.Y1).Scale(bounds.W(), -bounds.H())
|
||||||
|
span := TextSpan{Start: start, End: end}
|
||||||
|
if n := len(run.Spans); n > 0 && run.Spans[n-1].Start == start && run.Spans[n-1].End == end {
|
||||||
|
span = run.Spans[n-1]
|
||||||
|
v := span.Matrix
|
||||||
|
previous := canvas.Matrix{{v[0], v[2], v[4]}, {v[1], v[3], v[5]}}
|
||||||
|
if previous.Det() != 0 {
|
||||||
|
bounds = canvas.Rect{X1: 1, Y1: 1}.Add(canvas.Rect{X1: 1, Y1: 1}.Transform(previous.Inv().Mul(m)))
|
||||||
|
m = previous.Translate(bounds.X0, bounds.Y0).Scale(bounds.W(), bounds.H())
|
||||||
|
}
|
||||||
|
run.Spans = run.Spans[:n-1]
|
||||||
|
}
|
||||||
|
span.Matrix = [6]float64{m[0][0], m[1][0], m[0][1], m[1][1], m[0][2], m[1][2]}
|
||||||
|
run.Spans = append(run.Spans, span)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user