dsh-cait 1.1.6 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.ja.md +20 -10
- package/README.md +91 -72
- package/README.zh-CN.md +20 -10
- package/client/components.js +126 -13
- package/client/locales.js +74 -2
- package/client/styles.js +6 -2
- package/client.js +206 -17
- package/lib/aligner.js +6 -6
- package/lib/api.js +6 -0
- package/lib/docx.js +124 -26
- package/lib/import-export.js +115 -10
- package/lib/localization.js +1098 -0
- package/lib/persistence.js +14 -0
- package/lib/pptx.js +33 -1
- package/lib/qa.js +18 -0
- package/lib/store-utils.js +8 -3
- package/lib/store.js +14 -2
- package/lib/subtitles.js +213 -0
- package/lib/xlsx.js +176 -66
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -447,6 +447,9 @@ window.__ModuleLoader__.load({
|
|
|
447
447
|
.cait-textarea::placeholder{color:var(--dsw-alias-label-secondary)}
|
|
448
448
|
.cait-meta{font-size:10.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
|
|
449
449
|
.cait-actionbar{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-top:13px}
|
|
450
|
+
.cait-subbar{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-2)}
|
|
451
|
+
.cait-subtime{font-size:10.5px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
452
|
+
.cait-suboffset{width:54px;height:24px;padding:0 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:10.5px}
|
|
450
453
|
.cait-hint{margin-left:auto;font-size:10px;color:var(--dsw-alias-label-secondary)}
|
|
451
454
|
.cait-savebtn{width:31px;height:31px;border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
|
|
452
455
|
.cait-editor-foot{display:flex;align-items:center;gap:8px;margin-top:11px;font-size:10.5px;color:var(--dsw-alias-label-secondary)}
|
|
@@ -648,7 +651,8 @@ window.__ModuleLoader__.load({
|
|
|
648
651
|
.cait-libtoolbar{display:flex;align-items:center;gap:6px;padding:9px 12px 6px;flex-wrap:wrap;flex:0 0 auto}
|
|
649
652
|
.cait-libsearch{display:flex;align-items:center;gap:6px;min-width:150px;flex:1;height:29px;padding:0 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary)}
|
|
650
653
|
.cait-libsearch input{min-width:0;flex:1;border:0;outline:0;background:transparent;font-size:11.5px;color:var(--dsw-alias-label-primary)}
|
|
651
|
-
.cait-liblang{height:29px;padding:0
|
|
654
|
+
.cait-liblang{height:29px;padding:0 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-1);font-size:11px;flex:0 0 auto;min-width:132px;max-width:280px}
|
|
655
|
+
.cait-liblang:disabled{opacity:.65}
|
|
652
656
|
.cait-libwrite{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
653
657
|
.cait-libwritelabel{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
|
|
654
658
|
.cait-libscope{display:flex;align-items:center;gap:4px;padding:0 12px 8px;flex-wrap:wrap;flex:0 0 auto}
|
|
@@ -681,7 +685,7 @@ window.__ModuleLoader__.load({
|
|
|
681
685
|
.cait-alignhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
682
686
|
.cait-alignhead h2{margin:0 0 3px;font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
683
687
|
.cait-alignactions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
684
|
-
.cait-alignactions .cait-libinput{width:
|
|
688
|
+
.cait-alignactions .cait-libinput{width:170px;min-height:28px;height:28px;flex:0 0 auto}
|
|
685
689
|
.cait-alignfiles{display:flex;gap:8px;flex-wrap:wrap}
|
|
686
690
|
.cait-alignfile{display:flex;align-items:center;gap:8px;padding:7px 9px;border:1px dashed var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);min-width:0;flex:1 1 300px}
|
|
687
691
|
.cait-alignlabel{font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary);flex:0 0 auto}
|
|
@@ -1577,9 +1581,21 @@ window.__ModuleLoader__.load({
|
|
|
1577
1581
|
)
|
|
1578
1582
|
}
|
|
1579
1583
|
|
|
1580
|
-
function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onUnconfirm, onNext, onPrev, onSave, onUndo, onRedo, canUndo, canRedo, onToggleLock, onMerge, onSplit, onAdopt, onApplyTerm, onOpenBrief, onOpenResources, onOpenAnalysis, onOpenNotes, noteCount, qaRisk, textareaRef }) {
|
|
1584
|
+
function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onUnconfirm, onNext, onPrev, onSave, onUndo, onRedo, canUndo, canRedo, onToggleLock, onMerge, onSplit, onMergeCue, onSplitCue, onShiftSubtitles, onAdopt, onApplyTerm, onOpenBrief, onOpenResources, onOpenAnalysis, onOpenNotes, noteCount, qaRisk, textareaRef }) {
|
|
1581
1585
|
const hits = detail?.index === index ? detail.glossaryHits : []
|
|
1582
1586
|
const charCount = Array.from(draft || '').length
|
|
1587
|
+
const isSubtitle = doc.format === 'srt' || doc.format === 'vtt'
|
|
1588
|
+
const [shiftSeconds, setShiftSeconds] = useState('1')
|
|
1589
|
+
const cueStart = Number(segment.meta?.start)
|
|
1590
|
+
const cueEnd = Number(segment.meta?.end)
|
|
1591
|
+
const formatCue = (value) => {
|
|
1592
|
+
if (!Number.isFinite(value)) return '--:--.-'
|
|
1593
|
+
const total = Math.max(0, Math.round(value))
|
|
1594
|
+
const minutes = Math.floor(total / 60000)
|
|
1595
|
+
const seconds = Math.floor((total % 60000) / 1000)
|
|
1596
|
+
const millis = total % 1000
|
|
1597
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
|
|
1598
|
+
}
|
|
1583
1599
|
const progress = doc.progress
|
|
1584
1600
|
const remaining = progress.total - progress.confirmed
|
|
1585
1601
|
const statusLabel =
|
|
@@ -1847,16 +1863,47 @@ window.__ModuleLoader__.load({
|
|
|
1847
1863
|
h(Icon, { name: 'chevronLeft', size: 13 }),
|
|
1848
1864
|
t('editor.prev'),
|
|
1849
1865
|
),
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1866
|
+
isSubtitle
|
|
1867
|
+
? h(
|
|
1868
|
+
'div',
|
|
1869
|
+
{ className: 'cait-subbar' },
|
|
1870
|
+
h('span', { className: 'cait-subtime' }, `${formatCue(cueStart)} → ${formatCue(cueEnd)}`),
|
|
1871
|
+
h('input', {
|
|
1872
|
+
className: 'cait-suboffset',
|
|
1873
|
+
type: 'number',
|
|
1874
|
+
step: '0.1',
|
|
1875
|
+
value: shiftSeconds,
|
|
1876
|
+
onChange: (event) => setShiftSeconds(event.target.value),
|
|
1877
|
+
title: t('editor.shiftHint'),
|
|
1878
|
+
}),
|
|
1879
|
+
h(
|
|
1880
|
+
'button',
|
|
1881
|
+
{ className: 'cait-btn cait-btn-ghost', onClick: () => onShiftSubtitles(Math.round(Number(shiftSeconds) * 1000)), disabled: Boolean(busy) || Boolean(segment.locked) || !Number.isFinite(Number(shiftSeconds)) },
|
|
1882
|
+
t('editor.shift'),
|
|
1883
|
+
),
|
|
1884
|
+
h(
|
|
1885
|
+
'button',
|
|
1886
|
+
{ className: 'cait-btn cait-btn-ghost', onClick: onMergeCue, disabled: Boolean(busy) || Boolean(segment.locked) || index >= doc.segments.length - 1 },
|
|
1887
|
+
t('editor.mergeCue'),
|
|
1888
|
+
),
|
|
1889
|
+
h(
|
|
1890
|
+
'button',
|
|
1891
|
+
{ className: 'cait-btn cait-btn-ghost', onClick: () => onSplitCue(Math.round((cueStart + cueEnd) / 2)), disabled: Boolean(busy) || Boolean(segment.locked) || !(cueEnd > cueStart) },
|
|
1892
|
+
t('editor.splitCue'),
|
|
1893
|
+
),
|
|
1894
|
+
)
|
|
1895
|
+
: [
|
|
1896
|
+
h(
|
|
1897
|
+
'button',
|
|
1898
|
+
{ key: 'merge', className: 'cait-btn cait-btn-ghost', onClick: onMerge, disabled: Boolean(busy) || !canMerge, title: t('editor.mergeHint') },
|
|
1899
|
+
t('editor.merge'),
|
|
1900
|
+
),
|
|
1901
|
+
h(
|
|
1902
|
+
'button',
|
|
1903
|
+
{ key: 'split', className: 'cait-btn cait-btn-ghost', onClick: splitAtCursor, disabled: Boolean(busy) || !canSplit, title: t('editor.splitHint') },
|
|
1904
|
+
t('editor.split'),
|
|
1905
|
+
),
|
|
1906
|
+
],
|
|
1860
1907
|
h(
|
|
1861
1908
|
'button',
|
|
1862
1909
|
{ className: 'cait-iconbtn', onClick: onUndo, disabled: Boolean(busy) || !canUndo, title: t('editor.undo'), 'aria-label': t('editor.undo') },
|
|
@@ -2715,6 +2762,7 @@ window.__ModuleLoader__.load({
|
|
|
2715
2762
|
const sourceRef = useRef(null)
|
|
2716
2763
|
const targetRef = useRef(null)
|
|
2717
2764
|
const libraries = data?.tmLibraries || []
|
|
2765
|
+
const languageOptions = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'fr', 'de', 'es', 'pt-BR', 'ru', 'it', 'ar']
|
|
2718
2766
|
|
|
2719
2767
|
useEffect(() => {
|
|
2720
2768
|
if (!tmId && libraries.length) setTmId(libraries[0].id)
|
|
@@ -2815,7 +2863,12 @@ window.__ModuleLoader__.load({
|
|
|
2815
2863
|
action: async () => {
|
|
2816
2864
|
setWorking(true)
|
|
2817
2865
|
try {
|
|
2818
|
-
const written = await rpc('writeAlignment', {
|
|
2866
|
+
const written = await rpc('writeAlignment', {
|
|
2867
|
+
pairs,
|
|
2868
|
+
tmId: tmId || undefined,
|
|
2869
|
+
sourceLang: sourceLang || undefined,
|
|
2870
|
+
targetLang: targetLang || undefined,
|
|
2871
|
+
})
|
|
2819
2872
|
notify(t('align.written', { written: written.written, skipped: written.skipped }))
|
|
2820
2873
|
} catch (error) {
|
|
2821
2874
|
report(error)
|
|
@@ -2863,15 +2916,20 @@ window.__ModuleLoader__.load({
|
|
|
2863
2916
|
h(
|
|
2864
2917
|
'div',
|
|
2865
2918
|
{ className: 'cait-alignactions' },
|
|
2919
|
+
h('datalist', { id: 'cait-align-languages' }, languageOptions.map((code) => h('option', { key: code, value: code }))),
|
|
2866
2920
|
h('input', {
|
|
2867
2921
|
className: 'cait-libinput',
|
|
2868
2922
|
placeholder: t('align.sourceLang'),
|
|
2923
|
+
title: t('align.langHint'),
|
|
2924
|
+
list: 'cait-align-languages',
|
|
2869
2925
|
value: sourceLang,
|
|
2870
2926
|
onChange: (event) => setSourceLang(event.target.value),
|
|
2871
2927
|
}),
|
|
2872
2928
|
h('input', {
|
|
2873
2929
|
className: 'cait-libinput',
|
|
2874
2930
|
placeholder: t('align.targetLang'),
|
|
2931
|
+
title: t('align.langHint'),
|
|
2932
|
+
list: 'cait-align-languages',
|
|
2875
2933
|
value: targetLang,
|
|
2876
2934
|
onChange: (event) => setTargetLang(event.target.value),
|
|
2877
2935
|
}),
|
|
@@ -2881,8 +2939,15 @@ window.__ModuleLoader__.load({
|
|
|
2881
2939
|
className: 'cait-select cait-liblang',
|
|
2882
2940
|
value: tmId,
|
|
2883
2941
|
onChange: (event) => setTmId(event.target.value),
|
|
2942
|
+
title: t('align.tmHint'),
|
|
2943
|
+
'aria-label': t('align.tm'),
|
|
2944
|
+
disabled: !libraries.length,
|
|
2884
2945
|
},
|
|
2885
|
-
libraries.
|
|
2946
|
+
libraries.length
|
|
2947
|
+
? libraries.map((library) =>
|
|
2948
|
+
h('option', { key: library.id, value: library.id }, t('align.tmOption', { name: library.name })),
|
|
2949
|
+
)
|
|
2950
|
+
: h('option', { value: '' }, t('align.noTm')),
|
|
2886
2951
|
),
|
|
2887
2952
|
h(
|
|
2888
2953
|
'button',
|
|
@@ -3972,6 +4037,13 @@ window.__ModuleLoader__.load({
|
|
|
3972
4037
|
['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
|
|
3973
4038
|
['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
|
|
3974
4039
|
['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
|
|
4040
|
+
['apple-strings', t('modal.export.appleStrings'), t('modal.export.localizationGroup'), 'file'],
|
|
4041
|
+
['apple-stringsdict', t('modal.export.appleStringsdict'), t('modal.export.localizationGroup'), 'file'],
|
|
4042
|
+
['android', t('modal.export.android'), t('modal.export.localizationGroup'), 'file'],
|
|
4043
|
+
['resx', t('modal.export.resx'), t('modal.export.localizationGroup'), 'file'],
|
|
4044
|
+
['arb', t('modal.export.arb'), t('modal.export.localizationGroup'), 'file'],
|
|
4045
|
+
['yaml', t('modal.export.yaml'), t('modal.export.localizationGroup'), 'file'],
|
|
4046
|
+
['properties', t('modal.export.properties'), t('modal.export.localizationGroup'), 'file'],
|
|
3975
4047
|
['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
|
|
3976
4048
|
['xliff-review', t('modal.export.xliffReview'), t('modal.export.localizationGroup'), 'file'],
|
|
3977
4049
|
['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
|
|
@@ -5748,6 +5820,48 @@ window.__ModuleLoader__.load({
|
|
|
5748
5820
|
})
|
|
5749
5821
|
}
|
|
5750
5822
|
|
|
5823
|
+
const mergeCue = () => {
|
|
5824
|
+
if (!doc || !segment || busy) return
|
|
5825
|
+
openConfirm({
|
|
5826
|
+
title: t('modal.confirm.mergeTitle'),
|
|
5827
|
+
message: t('modal.confirm.mergeCue'),
|
|
5828
|
+
confirmLabel: t('editor.mergeCue'),
|
|
5829
|
+
action: async () => {
|
|
5830
|
+
const result = await rpc('mergeCues', { documentId: doc.id, index: selected, count: 2 })
|
|
5831
|
+
adoptState(result.state, result.index)
|
|
5832
|
+
notify(t('toast.cuesMerged'))
|
|
5833
|
+
},
|
|
5834
|
+
})
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
const splitCueAt = (at) => {
|
|
5838
|
+
if (!doc || !segment || busy) return
|
|
5839
|
+
openConfirm({
|
|
5840
|
+
title: t('modal.confirm.splitTitle'),
|
|
5841
|
+
message: t('modal.confirm.splitCue'),
|
|
5842
|
+
confirmLabel: t('editor.splitCue'),
|
|
5843
|
+
action: async () => {
|
|
5844
|
+
const result = await rpc('splitCue', { documentId: doc.id, index: selected, at })
|
|
5845
|
+
adoptState(result.state, result.index)
|
|
5846
|
+
notify(t('toast.cueSplit'))
|
|
5847
|
+
},
|
|
5848
|
+
})
|
|
5849
|
+
}
|
|
5850
|
+
|
|
5851
|
+
const shiftSubtitles = (offsetMs) => {
|
|
5852
|
+
if (!doc || !offsetMs || busy) return
|
|
5853
|
+
openConfirm({
|
|
5854
|
+
title: t('modal.confirm.shiftTitle'),
|
|
5855
|
+
message: t('modal.confirm.shiftCue'),
|
|
5856
|
+
confirmLabel: t('editor.shift'),
|
|
5857
|
+
action: async () => {
|
|
5858
|
+
const result = await rpc('shiftSubtitles', { documentId: doc.id, offsetMs })
|
|
5859
|
+
adoptState(result.state)
|
|
5860
|
+
notify(t('toast.subtitlesShifted'))
|
|
5861
|
+
},
|
|
5862
|
+
})
|
|
5863
|
+
}
|
|
5864
|
+
|
|
5751
5865
|
const saveBrief = async (brief) => {
|
|
5752
5866
|
if (!doc || busy) return false
|
|
5753
5867
|
setBusy('brief')
|
|
@@ -6615,6 +6729,9 @@ window.__ModuleLoader__.load({
|
|
|
6615
6729
|
onToggleLock: toggleLock,
|
|
6616
6730
|
onMerge: mergeCurrent,
|
|
6617
6731
|
onSplit: splitCurrent,
|
|
6732
|
+
onMergeCue: mergeCue,
|
|
6733
|
+
onSplitCue: splitCueAt,
|
|
6734
|
+
onShiftSubtitles: shiftSubtitles,
|
|
6618
6735
|
onNext: () => moveSegment(1),
|
|
6619
6736
|
onPrev: () => moveSegment(-1),
|
|
6620
6737
|
onSave: () => saveDraft(false),
|
|
@@ -6992,6 +7109,13 @@ window.__ModuleLoader__.load({
|
|
|
6992
7109
|
'modal.export.srt': 'SRT 字幕',
|
|
6993
7110
|
'modal.export.vtt': 'WebVTT 字幕',
|
|
6994
7111
|
'modal.export.po': 'PO 本地化',
|
|
7112
|
+
'modal.export.appleStrings': '.strings 字符串',
|
|
7113
|
+
'modal.export.appleStringsdict': '.stringsdict 复数',
|
|
7114
|
+
'modal.export.android': 'Android strings.xml',
|
|
7115
|
+
'modal.export.resx': '.resx 资源',
|
|
7116
|
+
'modal.export.arb': 'ARB 应用资源',
|
|
7117
|
+
'modal.export.yaml': 'YAML 本地化',
|
|
7118
|
+
'modal.export.properties': '.properties 属性',
|
|
6995
7119
|
'modal.export.xliff': 'XLIFF 1.2',
|
|
6996
7120
|
'modal.export.xliffReview': 'XLIFF 审校包',
|
|
6997
7121
|
'modal.export.reviewImport': '回填审校包',
|
|
@@ -7137,6 +7261,11 @@ window.__ModuleLoader__.load({
|
|
|
7137
7261
|
'align.readError': '读取文件失败。',
|
|
7138
7262
|
'align.sourceLang': '源语言(可选)',
|
|
7139
7263
|
'align.targetLang': '目标语言(可选)',
|
|
7264
|
+
'align.langHint': '可留空;例如 zh-CN、en',
|
|
7265
|
+
'align.tm': '写入记忆库',
|
|
7266
|
+
'align.tmHint': '对齐结果写入这个记忆库',
|
|
7267
|
+
'align.tmOption': '记忆库:{name}',
|
|
7268
|
+
'align.noTm': '暂无已挂载的记忆库',
|
|
7140
7269
|
'align.stats': '高置信 {high} · 待确认 {medium} · 可疑 {low}',
|
|
7141
7270
|
'align.unpairedSource': '未配对原文',
|
|
7142
7271
|
'align.unpairedTarget': '未配对译文',
|
|
@@ -7386,6 +7515,13 @@ window.__ModuleLoader__.load({
|
|
|
7386
7515
|
'modal.export.srt': 'SRT subtitles',
|
|
7387
7516
|
'modal.export.vtt': 'WebVTT subtitles',
|
|
7388
7517
|
'modal.export.po': 'PO localization',
|
|
7518
|
+
'modal.export.appleStrings': '.strings strings',
|
|
7519
|
+
'modal.export.appleStringsdict': '.stringsdict plurals',
|
|
7520
|
+
'modal.export.android': 'Android strings.xml',
|
|
7521
|
+
'modal.export.resx': '.resx resources',
|
|
7522
|
+
'modal.export.arb': 'ARB app resources',
|
|
7523
|
+
'modal.export.yaml': 'YAML localization',
|
|
7524
|
+
'modal.export.properties': '.properties',
|
|
7389
7525
|
'modal.export.xliff': 'XLIFF 1.2',
|
|
7390
7526
|
'modal.export.xliffReview': 'XLIFF review package',
|
|
7391
7527
|
'modal.export.reviewImport': 'Apply review package',
|
|
@@ -7530,8 +7666,13 @@ window.__ModuleLoader__.load({
|
|
|
7530
7666
|
'align.running': 'Aligning…',
|
|
7531
7667
|
'align.needFiles': 'Choose both files first.',
|
|
7532
7668
|
'align.readError': 'Could not read that file.',
|
|
7533
|
-
'align.sourceLang': 'Source language
|
|
7534
|
-
'align.targetLang': 'Target language
|
|
7669
|
+
'align.sourceLang': 'Source language',
|
|
7670
|
+
'align.targetLang': 'Target language',
|
|
7671
|
+
'align.langHint': 'Optional; for example zh-CN, en',
|
|
7672
|
+
'align.tm': 'Write to memory',
|
|
7673
|
+
'align.tmHint': 'Aligned pairs are written to this memory',
|
|
7674
|
+
'align.tmOption': 'Memory: {name}',
|
|
7675
|
+
'align.noTm': 'No mounted memory yet',
|
|
7535
7676
|
'align.stats': 'high {high} · to review {medium} · doubtful {low}',
|
|
7536
7677
|
'align.unpairedSource': 'Unpaired source',
|
|
7537
7678
|
'align.unpairedTarget': 'Unpaired target',
|
|
@@ -7781,6 +7922,13 @@ window.__ModuleLoader__.load({
|
|
|
7781
7922
|
'modal.export.srt': 'SRT 字幕',
|
|
7782
7923
|
'modal.export.vtt': 'WebVTT 字幕',
|
|
7783
7924
|
'modal.export.po': 'PO ローカライズ',
|
|
7925
|
+
'modal.export.appleStrings': '.strings 文字列',
|
|
7926
|
+
'modal.export.appleStringsdict': '.stringsdict 複数形',
|
|
7927
|
+
'modal.export.android': 'Android strings.xml',
|
|
7928
|
+
'modal.export.resx': '.resx リソース',
|
|
7929
|
+
'modal.export.arb': 'ARB アプリリソース',
|
|
7930
|
+
'modal.export.yaml': 'YAML ローカライズ',
|
|
7931
|
+
'modal.export.properties': '.properties',
|
|
7784
7932
|
'modal.export.xliff': 'XLIFF 1.2',
|
|
7785
7933
|
'modal.export.xliffReview': 'XLIFF 校閲パッケージ',
|
|
7786
7934
|
'modal.export.reviewImport': '校閲パッケージを反映',
|
|
@@ -7927,6 +8075,11 @@ window.__ModuleLoader__.load({
|
|
|
7927
8075
|
'align.readError': 'ファイルを読み込めませんでした。',
|
|
7928
8076
|
'align.sourceLang': '原文言語(任意)',
|
|
7929
8077
|
'align.targetLang': '訳文言語(任意)',
|
|
8078
|
+
'align.langHint': '任意;例: zh-CN、en',
|
|
8079
|
+
'align.tm': '保存先メモリ',
|
|
8080
|
+
'align.tmHint': '整列結果をこのメモリに保存します',
|
|
8081
|
+
'align.tmOption': 'メモリ: {name}',
|
|
8082
|
+
'align.noTm': 'メモリ未設定',
|
|
7930
8083
|
'align.stats': '高 {high} · 要確認 {medium} · 要注意 {low}',
|
|
7931
8084
|
'align.unpairedSource': '未対応の原文',
|
|
7932
8085
|
'align.unpairedTarget': '未対応の訳文',
|
|
@@ -8012,6 +8165,7 @@ window.__ModuleLoader__.load({
|
|
|
8012
8165
|
'qa.risk': '风险',
|
|
8013
8166
|
'qa.code.TERM_FORBIDDEN': '禁用术语',
|
|
8014
8167
|
'qa.code.LENGTH_DRIFT': '长度异常',
|
|
8168
|
+
'qa.code.SUBTITLE_OVERLAP': '字幕重叠',
|
|
8015
8169
|
'qa.fixCopy': '复制源文',
|
|
8016
8170
|
'qa.fixClear': '清空译文',
|
|
8017
8171
|
'qa.fixTerm': '插入术语',
|
|
@@ -8164,6 +8318,7 @@ window.__ModuleLoader__.load({
|
|
|
8164
8318
|
'qa.risk': 'Risk',
|
|
8165
8319
|
'qa.code.TERM_FORBIDDEN': 'Forbidden term',
|
|
8166
8320
|
'qa.code.LENGTH_DRIFT': 'Length drift',
|
|
8321
|
+
'qa.code.SUBTITLE_OVERLAP': 'Subtitle overlap',
|
|
8167
8322
|
'qa.fixCopy': 'Copy source',
|
|
8168
8323
|
'qa.fixClear': 'Clear target',
|
|
8169
8324
|
'qa.fixTerm': 'Insert term',
|
|
@@ -8316,6 +8471,7 @@ window.__ModuleLoader__.load({
|
|
|
8316
8471
|
'qa.risk': 'リスク',
|
|
8317
8472
|
'qa.code.TERM_FORBIDDEN': '禁止用語',
|
|
8318
8473
|
'qa.code.LENGTH_DRIFT': '長さの不一致',
|
|
8474
|
+
'qa.code.SUBTITLE_OVERLAP': '字幕の重なり',
|
|
8319
8475
|
'qa.fixCopy': '原文をコピー',
|
|
8320
8476
|
'qa.fixClear': '訳文を空にする',
|
|
8321
8477
|
'qa.fixTerm': '用語を挿入',
|
|
@@ -8428,6 +8584,17 @@ window.__ModuleLoader__.load({
|
|
|
8428
8584
|
'modal.confirm.merge': '合并会新建一个句段,并丢失两条原文各自的确认状态。确定继续?',
|
|
8429
8585
|
'modal.confirm.splitTitle': '拆分句段',
|
|
8430
8586
|
'modal.confirm.split': '拆分后两侧都会变成未确认状态,译文按比例切开。确定继续?',
|
|
8587
|
+
'editor.shift': '平移',
|
|
8588
|
+
'editor.shiftHint': '按秒平移当前文档所有字幕时间码;不能产生负时间码。',
|
|
8589
|
+
'editor.mergeCue': '合并下一条',
|
|
8590
|
+
'editor.splitCue': '按中点拆分',
|
|
8591
|
+
'toast.cuesMerged': '已合并字幕',
|
|
8592
|
+
'toast.cueSplit': '已拆分字幕',
|
|
8593
|
+
'toast.subtitlesShifted': '已平移字幕时间轴',
|
|
8594
|
+
'modal.confirm.shiftTitle': '平移字幕',
|
|
8595
|
+
'modal.confirm.shiftCue': '会平移当前文档所有字幕的时间码,且不修改译文。确定继续?',
|
|
8596
|
+
'modal.confirm.mergeCue': '合并当前字幕和下一条,时间范围取两者首尾;确定继续?',
|
|
8597
|
+
'modal.confirm.splitCue': '会在当前字幕时间范围的中点拆成两条,译文按比例切开;确定继续?',
|
|
8431
8598
|
})
|
|
8432
8599
|
Object.assign(EN, {
|
|
8433
8600
|
'editor.merge': 'Merge next',
|
|
@@ -8440,6 +8607,17 @@ window.__ModuleLoader__.load({
|
|
|
8440
8607
|
'modal.confirm.merge': 'Merging creates a new segment and discards the confirmation state of both originals. Continue?',
|
|
8441
8608
|
'modal.confirm.splitTitle': 'Split segment',
|
|
8442
8609
|
'modal.confirm.split': 'Both halves become unconfirmed and the target is split proportionally. Continue?',
|
|
8610
|
+
'editor.shift': 'Shift',
|
|
8611
|
+
'editor.shiftHint': 'Shift every subtitle cue in this document by seconds; negative timecodes are rejected.',
|
|
8612
|
+
'editor.mergeCue': 'Merge next cue',
|
|
8613
|
+
'editor.splitCue': 'Split at midpoint',
|
|
8614
|
+
'toast.cuesMerged': 'Cues merged',
|
|
8615
|
+
'toast.cueSplit': 'Cue split',
|
|
8616
|
+
'toast.subtitlesShifted': 'Subtitle timeline shifted',
|
|
8617
|
+
'modal.confirm.shiftTitle': 'Shift subtitles',
|
|
8618
|
+
'modal.confirm.shiftCue': 'Every cue in this document moves by the same offset. Translations are unchanged. Continue?',
|
|
8619
|
+
'modal.confirm.mergeCue': 'Merge this cue with the next one, using the outer time range. Continue?',
|
|
8620
|
+
'modal.confirm.splitCue': 'Split this cue at its midpoint; the translation is split proportionally. Continue?',
|
|
8443
8621
|
})
|
|
8444
8622
|
Object.assign(JA, {
|
|
8445
8623
|
'editor.merge': '次を結合',
|
|
@@ -8452,6 +8630,17 @@ window.__ModuleLoader__.load({
|
|
|
8452
8630
|
'modal.confirm.merge': '結合すると新しいセグメントになり、元の確認状態は失われます。続行しますか?',
|
|
8453
8631
|
'modal.confirm.splitTitle': 'セグメントを分割',
|
|
8454
8632
|
'modal.confirm.split': '分割後は両側とも未確認になり、訳文は比率で分割されます。続行しますか?',
|
|
8633
|
+
'editor.shift': 'シフト',
|
|
8634
|
+
'editor.shiftHint': '文書内の全字幕を秒単位でシフトします。負のタイムコードは拒否されます。',
|
|
8635
|
+
'editor.mergeCue': '次のキューを結合',
|
|
8636
|
+
'editor.splitCue': '中点で分割',
|
|
8637
|
+
'toast.cuesMerged': '字幕を結合しました',
|
|
8638
|
+
'toast.cueSplit': '字幕を分割しました',
|
|
8639
|
+
'toast.subtitlesShifted': '字幕タイムラインをシフトしました',
|
|
8640
|
+
'modal.confirm.shiftTitle': '字幕をシフト',
|
|
8641
|
+
'modal.confirm.shiftCue': '文書内の全キューを同じ量だけ移動します。訳文は変更しません。続行しますか?',
|
|
8642
|
+
'modal.confirm.mergeCue': '現在のキューと次を結合し、外側の時間範囲を使います。続行しますか?',
|
|
8643
|
+
'modal.confirm.splitCue': '現在のキューの中央で二分割し、訳文を比率で分けます。続行しますか?',
|
|
8455
8644
|
})
|
|
8456
8645
|
const UI_LANGS = [
|
|
8457
8646
|
['auto', ''],
|
package/lib/aligner.js
CHANGED
|
@@ -362,12 +362,12 @@ export const alignMethods = {
|
|
|
362
362
|
* stored are skipped, and nothing else in the store is touched: no document,
|
|
363
363
|
* no segment status.
|
|
364
364
|
*/
|
|
365
|
-
writeAlignment({ pairs, tmId } = {}) {
|
|
365
|
+
writeAlignment({ pairs, tmId, sourceLang, targetLang } = {}) {
|
|
366
366
|
const list = Array.isArray(pairs) ? pairs : []
|
|
367
367
|
if (!list.length) throw new CatError('ALIGN_EMPTY', '没有可写入的配对。')
|
|
368
368
|
const writeTmId = this.requireMountedTmLibrary(tmId)
|
|
369
|
-
const
|
|
370
|
-
const
|
|
369
|
+
const writeSourceLang = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
370
|
+
const writeTargetLang = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
371
371
|
let written = 0
|
|
372
372
|
let updated = 0
|
|
373
373
|
let skipped = 0
|
|
@@ -382,8 +382,8 @@ export const alignMethods = {
|
|
|
382
382
|
const existing = this.tm.find(
|
|
383
383
|
(entry) =>
|
|
384
384
|
entry.tmId === writeTmId &&
|
|
385
|
-
entry.sourceLang ===
|
|
386
|
-
entry.targetLang ===
|
|
385
|
+
entry.sourceLang === writeSourceLang &&
|
|
386
|
+
entry.targetLang === writeTargetLang &&
|
|
387
387
|
(entry.contextHash ?? null) === null &&
|
|
388
388
|
normalizeText(entry.source) === key,
|
|
389
389
|
)
|
|
@@ -391,7 +391,7 @@ export const alignMethods = {
|
|
|
391
391
|
skipped += 1
|
|
392
392
|
continue
|
|
393
393
|
}
|
|
394
|
-
this.upsertTM(sourceText, targetText,
|
|
394
|
+
this.upsertTM(sourceText, targetText, writeSourceLang, writeTargetLang, writeTmId, null)
|
|
395
395
|
if (existing) updated += 1
|
|
396
396
|
else written += 1
|
|
397
397
|
}
|
package/lib/api.js
CHANGED
|
@@ -23,6 +23,9 @@ const MUTATIONS = new Set([
|
|
|
23
23
|
'setSegmentLocked',
|
|
24
24
|
'mergeSegments',
|
|
25
25
|
'splitSegment',
|
|
26
|
+
'mergeCues',
|
|
27
|
+
'splitCue',
|
|
28
|
+
'shiftSubtitles',
|
|
26
29
|
'clearUnconfirmedTranslations',
|
|
27
30
|
'adoptSuggestion',
|
|
28
31
|
'proposeTranslation',
|
|
@@ -141,6 +144,9 @@ function createHandlers(store) {
|
|
|
141
144
|
setSegmentLocked: (params) => store.setSegmentLocked(params),
|
|
142
145
|
mergeSegments: (params) => store.mergeSegments(params),
|
|
143
146
|
splitSegment: (params) => store.splitSegment(params),
|
|
147
|
+
mergeCues: (params) => store.mergeCues(params),
|
|
148
|
+
splitCue: (params) => store.splitCue(params),
|
|
149
|
+
shiftSubtitles: (params) => store.shiftSubtitles(params),
|
|
144
150
|
getClearUnconfirmedPreview: (params) => store.clearUnconfirmedTranslations({ ...params, confirm: false }),
|
|
145
151
|
clearUnconfirmedTranslations: confirmDangerous((params) => store.clearUnconfirmedTranslations({ ...params, confirm: true })),
|
|
146
152
|
adoptSuggestion: (params) => store.adoptSuggestion(params),
|
package/lib/docx.js
CHANGED
|
@@ -239,6 +239,88 @@ function paragraphText(paragraph) {
|
|
|
239
239
|
return out.replace(/\n{3,}/g, '\n\n').trim()
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
function contentPartRank(name) {
|
|
243
|
+
if (name === 'word/document.xml') return 0
|
|
244
|
+
if (/^word\/header\d*\.xml$/i.test(name)) return 1
|
|
245
|
+
if (/^word\/footer\d*\.xml$/i.test(name)) return 2
|
|
246
|
+
if (name === 'word/footnotes.xml') return 3
|
|
247
|
+
if (name === 'word/endnotes.xml') return 4
|
|
248
|
+
return 9
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function contentPartNames(entries) {
|
|
252
|
+
return entries
|
|
253
|
+
.map((entry) => entry.name)
|
|
254
|
+
.filter((name) => contentPartRank(name) < 9)
|
|
255
|
+
.sort((left, right) => {
|
|
256
|
+
const rank = contentPartRank(left) - contentPartRank(right)
|
|
257
|
+
return rank || left.localeCompare(right)
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Collect every paragraph in one document part, including table cells, in
|
|
263
|
+
* document order. `docxParagraph` is the ordinal within that part, which is
|
|
264
|
+
* what the exporter patches; table/row/cell are informative metadata.
|
|
265
|
+
*/
|
|
266
|
+
function collectPartParagraphs(xml, partName) {
|
|
267
|
+
const pattern = /<w:tbl\b[^>]*>|<\/w:tbl>|<w:tr\b[^>]*>|<\/w:tr>|<w:tc\b[^>]*>|<\/w:tc>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi
|
|
268
|
+
const paragraphs = []
|
|
269
|
+
let tableDepth = 0
|
|
270
|
+
let tableIndex = -1
|
|
271
|
+
let tableCount = 0
|
|
272
|
+
let rowIndex = -1
|
|
273
|
+
let cellIndex = -1
|
|
274
|
+
let ordinal = 0
|
|
275
|
+
let match
|
|
276
|
+
|
|
277
|
+
while ((match = pattern.exec(xml)) !== null) {
|
|
278
|
+
const tag = match[0]
|
|
279
|
+
if (/^<w:tbl\b/i.test(tag)) {
|
|
280
|
+
if (tableDepth === 0) {
|
|
281
|
+
tableIndex = tableCount
|
|
282
|
+
tableCount += 1
|
|
283
|
+
rowIndex = -1
|
|
284
|
+
cellIndex = -1
|
|
285
|
+
}
|
|
286
|
+
tableDepth += 1
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (/^<\/w:tbl>/i.test(tag)) {
|
|
290
|
+
tableDepth = Math.max(0, tableDepth - 1)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
if (/^<w:tr\b/i.test(tag)) {
|
|
294
|
+
if (tableDepth > 0) {
|
|
295
|
+
rowIndex += 1
|
|
296
|
+
cellIndex = -1
|
|
297
|
+
}
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if (/^<\/w:tr>/i.test(tag)) continue
|
|
301
|
+
if (/^<w:tc\b/i.test(tag)) {
|
|
302
|
+
if (tableDepth > 0) cellIndex += 1
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (/^<\/w:tc>/i.test(tag)) continue
|
|
306
|
+
|
|
307
|
+
const inner = tag.replace(/^<w:p\b[^>]*>/i, '').replace(/<\/w:p>\s*$/i, '')
|
|
308
|
+
const text = paragraphText(inner)
|
|
309
|
+
if (text) {
|
|
310
|
+
const meta = { docxPart: partName, docxParagraph: ordinal }
|
|
311
|
+
if (tableDepth > 0) {
|
|
312
|
+
meta.table = tableIndex
|
|
313
|
+
meta.row = Math.max(0, rowIndex)
|
|
314
|
+
meta.cell = Math.max(0, cellIndex)
|
|
315
|
+
}
|
|
316
|
+
paragraphs.push({ text, meta })
|
|
317
|
+
}
|
|
318
|
+
ordinal += 1
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return paragraphs
|
|
322
|
+
}
|
|
323
|
+
|
|
242
324
|
/**
|
|
243
325
|
* Like parseDocx, but also returns the zero-based body paragraph ordinal for
|
|
244
326
|
* every non-empty paragraph. The ordinal counts all `<w:p>` elements outside
|
|
@@ -248,33 +330,35 @@ export function parseDocxDetailed(buffer) {
|
|
|
248
330
|
if (!Buffer.isBuffer(buffer) || buffer.length < 4) throw new Error('DOCX 内容为空')
|
|
249
331
|
if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) throw new Error('DOCX 文件头不是 ZIP')
|
|
250
332
|
|
|
251
|
-
const
|
|
252
|
-
|
|
333
|
+
const entries = readZipEntries(buffer)
|
|
334
|
+
const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
|
|
335
|
+
if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
|
|
253
336
|
|
|
254
|
-
const
|
|
255
|
-
const body = document.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)?.[1] ?? document
|
|
256
|
-
const content = withoutTables(body)
|
|
337
|
+
const parts = contentPartNames(entries)
|
|
257
338
|
const paragraphs = []
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
paragraphIndex += 1
|
|
339
|
+
for (const name of parts) {
|
|
340
|
+
const entry = entries.find((item) => item.name === name)
|
|
341
|
+
if (!entry) continue
|
|
342
|
+
const found = collectPartParagraphs(entry.data.toString('utf8'), name)
|
|
343
|
+
for (const paragraph of found) {
|
|
344
|
+
paragraphs.push({ ...paragraph, index: paragraphs.length })
|
|
345
|
+
}
|
|
266
346
|
}
|
|
267
347
|
|
|
268
348
|
if (!paragraphs.length) {
|
|
269
|
-
const fallback =
|
|
349
|
+
const fallback = documentEntry.data
|
|
350
|
+
.toString('utf8')
|
|
351
|
+
.replace(/<w:tab\b[^>]*\/>/g, '\t')
|
|
352
|
+
.replace(/<w:br\b[^>]*\/>/g, '\n')
|
|
270
353
|
const text = decodeXml(fallback.replace(/<[^>]*>/g, '')).replace(/\n{3,}/g, '\n\n').trim()
|
|
271
|
-
if (text) paragraphs.push({ text, index: 0 })
|
|
354
|
+
if (text) paragraphs.push({ text, index: 0, meta: { docxPart: 'word/document.xml', docxParagraph: 0 } })
|
|
272
355
|
}
|
|
273
356
|
|
|
274
357
|
if (!paragraphs.length) throw new Error('DOCX 中未找到可翻译的文本')
|
|
275
358
|
return {
|
|
276
359
|
text: paragraphs.map((paragraph) => paragraph.text).join('\n\n'),
|
|
277
360
|
paragraphs,
|
|
361
|
+
formatMeta: { docxParts: parts },
|
|
278
362
|
}
|
|
279
363
|
}
|
|
280
364
|
|
|
@@ -282,9 +366,9 @@ export function parseDocx(buffer) {
|
|
|
282
366
|
return parseDocxDetailed(buffer).text
|
|
283
367
|
}
|
|
284
368
|
|
|
285
|
-
/** Read
|
|
286
|
-
export function readDocxXml(buffer) {
|
|
287
|
-
const xml = readZipEntry(buffer,
|
|
369
|
+
/** Read one DOCX part as text; used by the export tests. */
|
|
370
|
+
export function readDocxXml(buffer, partName = 'word/document.xml') {
|
|
371
|
+
const xml = readZipEntry(buffer, partName)
|
|
288
372
|
return xml ? xml.toString('utf8') : null
|
|
289
373
|
}
|
|
290
374
|
|
|
@@ -355,19 +439,33 @@ function patchDocumentXml(xml, replacements) {
|
|
|
355
439
|
return `${xml.slice(0, start)}${patchBody(body, replacements)}${xml.slice(start + body.length)}`
|
|
356
440
|
}
|
|
357
441
|
|
|
442
|
+
function patchPartXml(xml, replacements) {
|
|
443
|
+
let ordinal = 0
|
|
444
|
+
return xml.replace(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi, (paragraph) => {
|
|
445
|
+
const current = ordinal
|
|
446
|
+
ordinal += 1
|
|
447
|
+
if (!replacements.has(current)) return paragraph
|
|
448
|
+
return patchParagraph(paragraph, replacements.get(current))
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
358
452
|
/** Patch translated text into the source package, preserving its formatting. */
|
|
359
|
-
export function buildDocx(template, replacements) {
|
|
453
|
+
export function buildDocx(template, replacements, partReplacements = null) {
|
|
360
454
|
const entries = readZipEntries(template)
|
|
361
455
|
const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
|
|
362
456
|
if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
|
|
363
|
-
const
|
|
364
|
-
|
|
457
|
+
const byPart = partReplacements instanceof Map
|
|
458
|
+
? partReplacements
|
|
459
|
+
: new Map([['word/document.xml', replacements]])
|
|
365
460
|
return writeZip(
|
|
366
|
-
entries.map((entry) =>
|
|
367
|
-
entry.name
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
461
|
+
entries.map((entry) => {
|
|
462
|
+
if (!byPart.has(entry.name)) return entry
|
|
463
|
+
const xml = entry.data.toString('utf8')
|
|
464
|
+
const patched = partReplacements instanceof Map
|
|
465
|
+
? patchPartXml(xml, byPart.get(entry.name))
|
|
466
|
+
: patchDocumentXml(xml, replacements)
|
|
467
|
+
return { name: entry.name, data: Buffer.from(patched, 'utf8') }
|
|
468
|
+
}),
|
|
371
469
|
)
|
|
372
470
|
}
|
|
373
471
|
|