mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
}
|
||||
@@ -833,6 +861,8 @@ button.outline-link:hover {
|
||||
display: block;
|
||||
max-width: none;
|
||||
transform-origin: 0 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
|
||||
+81
-4
@@ -50,6 +50,7 @@ const state = {
|
||||
continuous: false,
|
||||
renderAnnotations: true,
|
||||
pageCache: new Map(),
|
||||
selectedPages: new Set(),
|
||||
pageInFlight: new Map(),
|
||||
pageRenderQueue: [],
|
||||
pageRenderRunning: false,
|
||||
@@ -194,6 +195,12 @@ el.fitButton.addEventListener("click", fitWidth);
|
||||
el.fitHeightButton.addEventListener("click", fitHeight);
|
||||
el.continuousButton.addEventListener("click", toggleContinuous);
|
||||
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.exportPageButton.addEventListener("click", exportCurrentPage);
|
||||
el.exportButton.addEventListener("click", exportPDF);
|
||||
@@ -1471,6 +1478,7 @@ function renderPageFlow() {
|
||||
|
||||
function resetPageFlow() {
|
||||
state.pageCache.clear();
|
||||
state.selectedPages.clear();
|
||||
state.visiblePages.clear();
|
||||
for (const task of state.pageInFlight.values()) {
|
||||
task.resolve(null);
|
||||
@@ -1509,13 +1517,11 @@ function flowPageObserver() {
|
||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||
if (!entry.isIntersecting) {
|
||||
state.visiblePages.delete(index);
|
||||
if (entry.target.classList.contains("rendered")) {
|
||||
entry.target.querySelector(".page-surface").replaceChildren();
|
||||
entry.target.classList.remove("rendered");
|
||||
}
|
||||
unmountPage(index);
|
||||
continue;
|
||||
}
|
||||
state.visiblePages.add(index);
|
||||
state.selectedPages.delete(index);
|
||||
renderFlowPage(index, { openSeq, priority: 4 });
|
||||
}
|
||||
}, {
|
||||
@@ -1526,6 +1532,18 @@ function flowPageObserver() {
|
||||
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 = {}) {
|
||||
const openSeq = options.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}`);
|
||||
svg.classList.add("ofd-svg");
|
||||
surface.replaceChildren(svg);
|
||||
surface.append(createTextLayer(page.text));
|
||||
for (const link of page.links) {
|
||||
let url;
|
||||
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) {
|
||||
const shell = pageShell(index);
|
||||
if (!shell) {
|
||||
|
||||
@@ -164,6 +164,14 @@ func renderPage(args []js.Value) (any, error) {
|
||||
if err != nil {
|
||||
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))
|
||||
for i, link := range page.Links {
|
||||
links[i] = map[string]any{
|
||||
@@ -182,6 +190,7 @@ func renderPage(args []js.Value) (any, error) {
|
||||
"height": page.Height,
|
||||
"svg": page.SVG,
|
||||
"links": links,
|
||||
"text": js.Global().Get("JSON").Call("parse", string(textData)),
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -270,10 +270,10 @@ func (s *Session) SetFonts(fonts []FontFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchPage 搜索指定页面,复用当前会话的文字索引
|
||||
// 入参: index 页面索引, query 搜索文字
|
||||
// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息
|
||||
func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) {
|
||||
// PageText 获取指定页面文字,复用当前会话的文字索引
|
||||
// 入参: index 页面索引
|
||||
// 返回: *ofdgo.PageText 页面文字, error 错误信息
|
||||
func (s *Session) PageText(index int) (*ofdgo.PageText, error) {
|
||||
text := s.textCache[index]
|
||||
if text == nil {
|
||||
_, page, err := s.pageContent(index)
|
||||
@@ -286,6 +286,17 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
return
|
||||
}
|
||||
face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal)
|
||||
var metrics canvas.FontMetrics
|
||||
if textRun != nil {
|
||||
metrics = face.Metrics()
|
||||
}
|
||||
glyphTransforms := r.textObjectGlyphTransforms(fontID, obj)
|
||||
hasUnderline := strings.Contains(obj.Decoration, "Underline")
|
||||
_, 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++ {
|
||||
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
|
||||
}
|
||||
|
||||
+45
-3
@@ -31,9 +31,18 @@ type PageText struct {
|
||||
// TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米
|
||||
// Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域
|
||||
type TextRun struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Boxes []Box `json:"boxes"`
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
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字符区间
|
||||
@@ -176,3 +185,36 @@ func unionTextBox(a, b Box) Box {
|
||||
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}
|
||||
}
|
||||
|
||||
// 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