Skip to content

Commit 048c61a

Browse files
committed
Refine reading emphasis and remove duplicate fullscreen menu
1 parent fa86c06 commit 048c61a

7 files changed

Lines changed: 62 additions & 27 deletions

File tree

‎electron/main.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {join} from 'node:path';
88
import {mkdtempSync} from 'node:fs';
99
import {tmpdir,release} from 'node:os';
1010
const windowsBuild=process.platform==='win32'?Number(release().split('.')[2]):0;
11+
// AppKit otherwise inserts a second full-screen item beside our togglefullscreen role.
12+
if(process.platform==='darwin')systemPreferences.setUserDefault('NSFullScreenMenuItemEverywhere','boolean',false);
1113
const smoke=process.argv.find(a=>a.startsWith('--smoke-test='))?.split('=')[1];
1214
if(smoke&&app.isPackaged&&!process.execPath.includes('PDFMathReader Tests.app'))throw Error('Mock tests require the isolated test application.');
1315
if(smoke){

‎server/topic-sentences.test.mjs‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {test} from 'node:test';
22
import assert from 'node:assert/strict';
3-
import {firstSentenceLength,topicSentenceRanges} from '../src/topic-sentences.mjs';
3+
import {firstSentenceLength,topicSentenceLength,topicSentenceRanges} from '../src/topic-sentences.mjs';
44

55
const run=(text,x,y,width=160,height=10,extra={})=>({text,x,y,width,height,fontSize:extra.fontSize??height,...extra});
6+
const longTail=Array.from({length:61},(_,index)=>`context${index+1}`).join(' ');
67

78
test('firstSentenceLength keeps leading offsets, closes quotes, and handles abbreviations and decimals',()=>{
89
const text=' Dr. Smith reports 3.14. “Next sentence.”';
@@ -17,16 +18,35 @@ test('firstSentenceLength segments CJK sentences',()=>{
1718
assert.equal(firstSentenceLength('첫 문장입니다. 다음 문장입니다.','ko'), '첫 문장입니다.'.length);
1819
});
1920

21+
test('topicSentenceLength gates at more than 60 weighted words',()=>{
22+
const sixtyWords=Array.from({length:60},(_,index)=>`word${index+1}`).join(' ');
23+
assert.equal(topicSentenceLength(`${sixtyWords}. 🙂`), 0);
24+
const overThreshold=`${sixtyWords} extra. Next.`;
25+
assert.equal(topicSentenceLength(overThreshold), `${sixtyWords} extra.`.length);
26+
});
27+
28+
test('topicSentenceLength counts Han, Hiragana, Katakana, and Hangul characters as half words',()=>{
29+
const mixed='漢'.repeat(30)+'あ'.repeat(30)+'カ'.repeat(30)+'한'.repeat(30);
30+
const text=`${mixed}。次。`;
31+
assert.equal(topicSentenceLength(text,'ja'), `${mixed}。`.length);
32+
});
33+
34+
test('topicSentenceLength excludes a long single sentence',()=>{
35+
const latin=Array.from({length:61},(_,index)=>`word${index+1}`).join(' ');
36+
assert.equal(topicSentenceLength(`${latin}.`), 0);
37+
assert.equal(topicSentenceLength(`${'漢'.repeat(121)}。`,'zh'), 0);
38+
});
39+
2040
test('topicSentenceRanges limits a single run to its first sentence',()=>{
21-
const text='First sentence. Second sentence.';
41+
const text=`First sentence. Second sentence. ${longTail}`;
2242
assert.deepEqual(topicSentenceRanges([run(text,10,10)]), [{index: 0, start: 0, end: 'First sentence.'.length}]);
2343
});
2444

2545
test('topicSentenceRanges joins same-paragraph lines and maps offsets back to runs',()=>{
2646
const runs=[
2747
run('First sentence starts',20,10),
2848
run('on the next line.',20,22),
29-
run('Second sentence.',20,34)
49+
run(`Second sentence. ${longTail}`,20,34)
3050
];
3151
assert.deepEqual(topicSentenceRanges(runs), [
3252
{index: 0, start: 0, end: runs[0].text.length},
@@ -35,9 +55,9 @@ test('topicSentenceRanges joins same-paragraph lines and maps offsets back to ru
3555
});
3656

3757
test('topicSentenceRanges keeps CJK line joins unspaced and preserves decimal abbreviations',()=>{
38-
const runs=[run('Dr. Smith uses 3.14.',10,10),run('这是下一句。',10,22)];
58+
const runs=[run('Dr. Smith uses 3.14.',10,10),run(`这是下一句。 ${longTail}`,10,22)];
3959
assert.deepEqual(topicSentenceRanges(runs), [{index: 0, start: 0, end: runs[0].text.length}]);
40-
const cjk=[run('这是第一句',10,10),run('继续。第二句。',10,22)];
60+
const cjk=[run('这是第一句',10,10),run(`继续。第二句。 ${longTail}`,10,22)];
4161
assert.deepEqual(topicSentenceRanges(cjk), [
4262
{index: 0, start: 0, end: cjk[0].text.length},
4363
{index: 1, start: 0, end: '继续。'.length}
@@ -48,8 +68,8 @@ test('topicSentenceRanges uses explicit paragraph boxes for overlapping columns'
4868
const runs=[
4969
run('Left starts',10,10,80),
5070
run('Right starts',210,10,80),
51-
run('left ends. Left next.',10,22,80),
52-
run('right ends. Right next.',210,22,80)
71+
run(`left ends. Left next. ${longTail}`,10,22,80),
72+
run(`right ends. Right next. ${longTail}`,210,22,80)
5373
];
5474
const boxes=[
5575
{id:'left',x:0,y:0,width:100,height:60},
@@ -66,9 +86,9 @@ test('topicSentenceRanges uses explicit paragraph boxes for overlapping columns'
6686
test('topicSentenceRanges falls back to vertical gaps, indentation, and font size',()=>{
6787
const runs=[
6888
run('First paragraph begins',20,10,160,10,{fontSize:10}),
69-
run('and finishes. Later.',20,22,160,10,{fontSize:10}),
89+
run(`and finishes. Later. ${longTail}`,20,22,160,10,{fontSize:10}),
7090
run('Second paragraph starts',42,52,160,14,{fontSize:14}),
71-
run('and has more. Later.',42,68,160,14,{fontSize:14})
91+
run(`and has more. Later. ${longTail}`,42,68,160,14,{fontSize:14})
7292
];
7393
assert.deepEqual(topicSentenceRanges(runs), [
7494
{index:0,start:0,end:runs[0].text.length},

‎src/App.vue‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎src/ReadingTextLayer.vue‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,22 @@ function drawEmphasis(){
1414
if(props.emphasizeInformation){
1515
const boxes=[];for(const mark of host.value.querySelectorAll('.pdf-information-keyword')){const range=window.document.createRange();range.selectNodeContents(mark);for(const r of range.getClientRects())boxes.push({x:r.left-pageBounds.left,y:r.top-pageBounds.top,width:r.width,height:r.height});}
1616
highlights=window.document.createElement('div');highlights.className='information-highlights';highlights.setAttribute('aria-hidden','true');
17-
for(const r of mergeInformationRects(boxes)){const rect=window.document.createElement('div');rect.className='information-highlight-rect';Object.assign(rect.style,{left:r.x+'px',top:r.y+'px',width:r.width+'px',height:r.height+'px'});highlights.append(rect);}
17+
for(const r of mergeInformationRects(boxes)){const rect=window.document.createElement('div');rect.className='information-highlight-rect';Object.assign(rect.style,{left:r.x+'px',top:(r.y+r.height-Math.max(1,r.height*.08))+'px',width:r.width+'px',height:Math.max(1,r.height*.08)+'px'});highlights.append(rect);}
1818
host.value.append(highlights);
1919
}
2020
if(!props.emphasizeTopicSentences)return;
2121
const source=host.value.parentElement?.querySelector(':scope > canvas');if(!source?.width||!source.height)return;
2222
const bounds=host.value.getBoundingClientRect();if(!bounds.width||!bounds.height)return;
2323
const rects=[];for(const strong of host.value.querySelectorAll('.pdf-topic-sentence')){const range=window.document.createRange();range.selectNodeContents(strong);rects.push(...range.getClientRects());}
2424
if(!rects.length)return;
25-
overlay=window.document.createElement('canvas');overlay.className='topic-sentence-overlay';overlay.width=source.width;overlay.height=source.height;
25+
overlay=window.document.createElement('canvas');overlay.className='topic-sentence-overlay';overlay.setAttribute('aria-hidden','true');overlay.width=source.width;overlay.height=source.height;
2626
const context=overlay.getContext('2d'),sx=source.width/bounds.width,sy=source.height/bounds.height;
27-
context.globalCompositeOperation='darken';
28-
for(const r of rects){context.save();context.beginPath();context.rect((r.left-bounds.left)*sx-.5*sx,(r.top-bounds.top)*sy, r.width*sx+sx,r.height*sy);context.clip();for(const offset of [-.3,0,.3])context.drawImage(source,offset*sx,0);context.restore();}
29-
// Keep only the glyph ink so the overlay never obscures the rest of the PDF.
30-
const pixels=context.getImageData(0,0,source.width,source.height),data=pixels.data;
31-
for(let i=0;i<data.length;i+=4){if(!data[i+3])continue;const white=Math.min(data[i],data[i+1],data[i+2]),alpha=255-white;if(!alpha){data[i+3]=0;continue;}for(let c=0;c<3;c++)data[i+c]=(data[i+c]-white)*255/alpha;data[i+3]=alpha;}
32-
context.putImageData(pixels,0,0);host.value.append(overlay);
27+
// A translucent wash marks the sentence without changing the PDF glyphs.
28+
const style=getComputedStyle(host.value);
29+
context.fillStyle=style.getPropertyValue('--system-accent').trim()||style.getPropertyValue('--accent').trim()||'#007aff';
30+
context.globalAlpha=.1;
31+
for(const r of mergeInformationRects(rects.map(r=>({x:r.left-bounds.left,y:r.top-bounds.top,width:r.width,height:r.height}))))context.fillRect(r.x*sx,r.y*sy,r.width*sx,r.height*sy);
32+
host.value.append(overlay);
3333
}
3434
onMounted(()=>{frameHost=host.value?.parentElement;frameHost?.addEventListener('pdf-frame-presented',drawEmphasis);});
3535
function emphasize(){
@@ -72,7 +72,7 @@ onBeforeUnmount(()=>{generation++;layer?.cancel();frameHost?.removeEventListener
7272
<style>
7373
.reading-text-layer.topic-layer-only{pointer-events:none;user-select:none}
7474
.reading-text-layer :is(.pdf-topic-sentence,.pdf-information-keyword){position:static;font:inherit;color:transparent;background:transparent;padding:0;white-space:inherit;transform:none}
75-
.reading-text-layer .topic-sentence-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;transform:none;z-index:2;background:transparent;min-height:0;border-radius:0}
75+
.reading-text-layer .topic-sentence-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;transform:none;z-index:2;background:transparent;min-height:0;border-radius:0;mix-blend-mode:multiply}
7676
.reading-text-layer .information-highlights{position:absolute;inset:0;pointer-events:none;user-select:none;transform:none;z-index:1}
77-
.information-highlight-rect{position:absolute;pointer-events:none;background:color-mix(in srgb,var(--system-accent,var(--accent)) 24%,transparent);border-radius:2px}
77+
.information-highlight-rect{position:absolute;pointer-events:none;background:color-mix(in srgb,var(--system-accent,var(--accent)) 65%,transparent);border-radius:1px}
7878
</style>

‎src/TopicSentenceText.vue‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<script setup>
22
import {computed} from 'vue';
33
import {uiLanguage} from './i18n.mjs';
4-
import {firstSentenceLength} from './topic-sentences.mjs';
4+
import {topicSentenceLength} from './topic-sentences.mjs';
55
import {informationTextSegments} from './information-emphasis.mjs';
66
const props=defineProps({text:String,enabled:Boolean,information:Boolean});
7-
const parts=computed(()=>informationTextSegments(props.text||'',{topicEnd:props.enabled?firstSentenceLength(props.text||'',uiLanguage.value):0,emphasizeInformation:props.information}));
7+
const parts=computed(()=>informationTextSegments(props.text||'',{topicEnd:props.enabled?topicSentenceLength(props.text||'',uiLanguage.value):0,emphasizeInformation:props.information}));
88
</script>
99
<template><template v-for="part in parts" :key="part.start"><component :is="part.topic?'strong':'span'" :class="{'topic-sentence-text':part.topic}"><mark v-if="part.important" class="information-keyword">{{part.text}}</mark><template v-else>{{part.text}}</template></component></template></template>
1010
<style>
11-
.topic-sentence-text,.information-keyword.topic-sentence-text{font-weight:700}
12-
.information-keyword{font:inherit;color:inherit;background:color-mix(in srgb,var(--system-accent,var(--accent)) 24%,transparent);padding:0;border-radius:2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}
11+
.topic-sentence-text{font-weight:inherit;background:color-mix(in srgb,var(--system-accent,var(--accent)) 10%,transparent);border-radius:2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}
12+
.information-keyword{font:inherit;color:inherit;background:transparent;padding:0;text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--system-accent,var(--accent)) 65%,transparent);text-decoration-thickness:.08em;text-underline-offset:.16em;text-decoration-skip-ink:auto}
1313
</style>

‎src/i18n.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,9 +714,11 @@ for(const [locale,label] of Object.entries(annotationPurpleLabels))annotationBro
714714
const annotationUnknownColors={en:'No color','zh-CN':'无颜色','zh-TW':'無顏色',ja:'色なし',fr:'Sans couleur',es:'Sin color',ko:'색상 없음'};
715715
for(const [locale,label] of Object.entries(annotationUnknownColors))annotationBrowserMessages[locale].unknownColor=label;
716716
for(const [locale,labels] of Object.entries(annotationBrowserMessages))messages[locale].annotationBrowser=labels;
717-
const topicSentenceLabels={en:'Emphasize topic sentences','zh-CN':'强调主题句','zh-TW':'強調主題句',ja:'段落の最初の文を強調',fr:'Mettre en gras la première phrase',es:'Destacar la primera oración',ko:'문단의 첫 문장 강조'};
717+
const topicSentenceLabels={en:'Emphasize topic sentences','zh-CN':'强调主题句','zh-TW':'強調主題句',ja:'段落の最初の文を強調',fr:'Mettre en évidence la première phrase',es:'Destacar la primera oración',ko:'문단의 첫 문장 강조'};
718718
for(const [locale,label] of Object.entries(topicSentenceLabels))messages[locale].settings.emphasizeTopicSentences=label;
719-
const informationLabels={en:['Emphasize information','Highlight research results, ordering, reasoning, discovery and comparison keywords.'],'zh-CN':['强调信息','用系统强调色高亮科研结果、序数、逻辑、发现和比较关键词。'],'zh-TW':['強調資訊','用系統強調色醒目標示科研結果、序數、邏輯、發現與比較關鍵詞。'],ja:['重要情報を強調','研究結果・順序・論理・発見・比較のキーワードを強調します。'],fr:['Mettre en évidence les informations','Surligner les mots clés des résultats, de l’ordre, du raisonnement, des découvertes et des comparaisons.'],es:['Destacar información','Resaltar palabras clave de resultados, orden, lógica, descubrimientos y comparaciones.'],ko:['중요 정보 강조','연구 결과, 순서, 논리, 발견 및 비교 관련 키워드를 강조합니다.']};
719+
const topicSentenceHints={en:'A soft tint marks the first sentence of paragraphs over 60 words or 120 CJK characters. Single-sentence paragraphs are skipped.','zh-CN':'以淡色底纹标示超过 60 个词或 120 个中日韩字符的长段落首句;只有一句的段落不强调。','zh-TW':'以淡色底紋標示超過 60 個詞或 120 個中日韓字元的長段落首句;只有一句的段落不強調。',ja:'60語または120文字を超える段落の最初の文を淡く表示します。1文のみの段落は対象外です。',ko:'60단어 또는 한중일 문자 120자를 넘는 문단의 첫 문장을 옅게 표시합니다. 한 문장인 문단은 제외합니다.',fr:'Une teinte légère marque la première phrase des paragraphes de plus de 60 mots ou 120 caractères CJK. Les paragraphes à une seule phrase sont exclus.',es:'Un fondo suave marca la primera oración de párrafos de más de 60 palabras o 120 caracteres CJK. Se omiten los párrafos de una sola oración.'};
720+
for(const [locale,hint] of Object.entries(topicSentenceHints))messages[locale].settings.emphasizeTopicSentencesHint=hint;
721+
const informationLabels={en:['Emphasize information','Subtle underlines mark research results, ordering, reasoning, discovery and comparison keywords.'],'zh-CN':['强调信息','用细下划线标示科研结果、序数、逻辑、发现和比较关键词。'],'zh-TW':['強調資訊','用細底線標示科研結果、序數、邏輯、發現與比較關鍵詞。'],ja:['重要情報を強調','研究結果・順序・論理・発見・比較のキーワードに細い下線を表示します。'],fr:['Mettre en évidence les informations','Souligner discrètement les mots clés des résultats, de l’ordre, du raisonnement, des découvertes et des comparaisons.'],es:['Destacar información','Subrayar suavemente palabras clave de resultados, orden, lógica, descubrimientos y comparaciones.'],ko:['중요 정보 강조','연구 결과, 순서, 논리, 발견 및 비교 관련 키워드에 가는 밑줄을 표시합니다.']};
720722
for(const [locale,[label,hint]] of Object.entries(informationLabels)){messages[locale].settings.emphasizeInformation=label;messages[locale].settings.emphasizeInformationHint=hint;}
721723
export const UI_MESSAGES=messages;
722724

‎src/topic-sentences.mjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ export function firstSentenceLength(text, locale = 'en') {
117117
return source ? intlSentenceEnd(source, locale) : 0;
118118
}
119119

120+
/** Emphasize only multi-sentence paragraphs over 60 word-equivalents. */
121+
export function topicSentenceLength(text, locale = 'en') {
122+
const source = textValue(text);
123+
const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
124+
const characters = source.match(cjk)?.length || 0;
125+
const words = source.replace(cjk, ' ').match(/[\p{L}\p{N}]+(?:['’\-][\p{L}\p{N}]+)*/gu)?.length || 0;
126+
if (words + characters / 2 <= 60) return 0;
127+
const end = firstSentenceLength(source, locale);
128+
return source.slice(end).trim() ? end : 0;
129+
}
130+
120131
function numeric(value) {
121132
const result = Number(value);
122133
return Number.isFinite(result) ? result : undefined;
@@ -275,7 +286,7 @@ function rangesForIndices(indices, normalized, locale) {
275286
logical += text;
276287
parts.push({index, start, end: logical.length});
277288
}
278-
const sentenceEnd = firstSentenceLength(logical, locale);
289+
const sentenceEnd = topicSentenceLength(logical, locale);
279290
if (!sentenceEnd) return [];
280291
const ranges = [];
281292
for (const part of parts) {

0 commit comments

Comments
 (0)