mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 15:56:37 +08:00
优化,阅读问题处理
This commit is contained in:
@@ -26,14 +26,20 @@ type AnalyzeRule struct {
|
||||
chapterVars map[string]string
|
||||
bookVars map[string]string
|
||||
vars map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// sourceDefaults 书源 JSON 的 variables 字段(作者设定的默认值)。
|
||||
// 它只作为 @get 的兜底:显式保存过的书源变量与会话内 @put 的值优先级更高。
|
||||
sourceDefaults map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。
|
||||
bookMeta map[string]any
|
||||
// bookCustom 书籍自定义变量(对应 legado Book.variableMap),
|
||||
// 由规则 JS 的 book.getVariable / book.putVariable 读写。
|
||||
bookCustom map[string]string
|
||||
// bookVarPutter 由服务层注册:book.putVariable 写入后触发,
|
||||
// 用于把变量变更持久化回书架记录(对应 legado 的 Book.upVariable)。
|
||||
bookVarPutter func()
|
||||
// bookTypeOverride 书源在规则 JS 里给 book.type 赋的值
|
||||
// (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。
|
||||
bookTypeOverride *int
|
||||
@@ -120,6 +126,17 @@ func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// BookCustomVars 返回书籍自定义变量的当前值(可能被 book.putVariable 改过),
|
||||
// 服务层据此把变更写回书架记录。没有书籍上下文时为 nil。
|
||||
func (a *AnalyzeRule) BookCustomVars() map[string]string {
|
||||
return a.bookCustom
|
||||
}
|
||||
|
||||
// RegisterBookVariablePutter 注册书籍变量变更回调(book.putVariable 写入后触发)。
|
||||
func (a *AnalyzeRule) RegisterBookVariablePutter(putter func()) {
|
||||
a.bookVarPutter = putter
|
||||
}
|
||||
|
||||
// SetBookType 记录书源声明的书籍类型(legado Book.type)。
|
||||
func (a *AnalyzeRule) SetBookType(t int) {
|
||||
v := t
|
||||
@@ -154,6 +171,14 @@ func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceDefaults 注入书源 variables 字段的默认值(@get 的兜底)。
|
||||
// 显式保存过的源变量与会话内 @put 的值优先级都高于它。
|
||||
func (a *AnalyzeRule) SetSourceDefaults(vars map[string]string) {
|
||||
if len(vars) > 0 {
|
||||
a.sourceDefaults = vars
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
|
||||
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
|
||||
a.sourceGetter = getter
|
||||
@@ -172,13 +197,19 @@ func jsonpathGet(path string, root any) (v any, err error) {
|
||||
|
||||
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
|
||||
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → source。
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → 书源持久变量。
|
||||
//
|
||||
// 「书源持久变量」是 @put 在 legado 里的真实落点(BaseSource.putVariable):
|
||||
// 写进去的值跨请求可见。书源默认变量(variables 字段)与书籍自定义变量都
|
||||
// 不是这个通道,所以最后才回退到 sourcePutter,而不是写一次性的 bookVars。
|
||||
func (a *AnalyzeRule) Put(key, value string) string {
|
||||
switch {
|
||||
case a.chapterVars != nil:
|
||||
a.chapterVars[key] = value
|
||||
case a.bookVars != nil:
|
||||
a.bookVars[key] = value
|
||||
case a.sourcePutter != nil:
|
||||
a.sourcePutter(key, value)
|
||||
default:
|
||||
if a.vars == nil {
|
||||
a.vars = map[string]string{}
|
||||
@@ -189,6 +220,7 @@ func (a *AnalyzeRule) Put(key, value string) string {
|
||||
}
|
||||
|
||||
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
|
||||
// 查找顺序 chapter → book → book 变量之上的显式源变量 → 书源默认变量。
|
||||
func (a *AnalyzeRule) Get(key string) string {
|
||||
switch key {
|
||||
case "bookName":
|
||||
@@ -212,6 +244,9 @@ func (a *AnalyzeRule) Get(key string) string {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v, ok := a.sourceDefaults[key]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -247,6 +247,12 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
// 字节参数可能是 ArrayBuffer / typed array:这类值的 Export() 返回属性 map,
|
||||
// 只有 ExportTo 才能取回字节,旧写法会退化成 String(value) 得到 "[object ArrayBuffer]"。
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(call.Arguments[0], &buf); err == nil {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
if buf, ok := call.Arguments[0].Export().([]byte); ok {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
@@ -641,6 +647,9 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
// 任意脚本执行面,暂不支持,保持明确报错。
|
||||
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
|
||||
|
||||
// 字体混淆还原(queryTTF / queryBase64TTF / replaceFont)。
|
||||
r.installFontBridge(vm, set)
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ type JSConfig struct {
|
||||
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
|
||||
// 为空时这些函数抛出明确错误。
|
||||
CacheDir string
|
||||
// FetchBytes 拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
// 由服务层注入,复用书源 header / Cookie / 限速 / 重试。
|
||||
FetchBytes func(absURL string) ([]byte, error)
|
||||
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
|
||||
Ctx context.Context
|
||||
}
|
||||
@@ -196,6 +199,18 @@ func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
|
||||
return r.cfg.Fetch(req)
|
||||
}
|
||||
|
||||
// cacheNamespace cache 对象的命名空间:优先取书源 URL,退回 BaseURL。
|
||||
func (c JSConfig) cacheNamespace() string {
|
||||
if c.SourceProps != nil {
|
||||
if v, ok := c.SourceProps["bookSourceUrl"]; ok {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
@@ -208,7 +223,8 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// cache 按书源命名空间隔离:namespace 取书源 URL,持久层落 CacheDir/reader-js-cache。
|
||||
vm.Set("cache", newCacheObject(vm, cfg.cacheNamespace(), cfg.CacheDir))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
@@ -558,6 +574,55 @@ func compileRuleJS(js string) (*goja.Program, error) {
|
||||
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
|
||||
}
|
||||
|
||||
// RunImageDecode 执行图片字节二次解密 JS(coverDecodeJs / ruleContent.imageDecode)。
|
||||
//
|
||||
// 对应 legado ImageUtils.getDecodeResult:绑定 result=图片字节、src=图片地址,
|
||||
// 规则返回解密后的字节(ArrayBuffer / typed array)。执行失败返回 error,
|
||||
// 调用方决定是回 502 还是原样透传。
|
||||
func (r *JSRunner) RunImageDecode(js string, data []byte, src string) ([]byte, error) {
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("book", nil)
|
||||
vm.Set("chapter", nil)
|
||||
vm.Set("title", nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", vm.ToValue(vm.NewArrayBuffer(data)))
|
||||
vm.Set("src", src)
|
||||
vm.Set("key", nil)
|
||||
vm.Set("page", nil)
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileRuleJS(stripRuleJSWrapper(js))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密 JS 编译失败: %w", err)
|
||||
}
|
||||
g := newInterruptGuard(vm, r.cfg.Timeout, "图片解密超时")
|
||||
r.setGuard(g)
|
||||
defer func() {
|
||||
g.Stop()
|
||||
r.setGuard(nil)
|
||||
}()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密失败: %v", err)
|
||||
}
|
||||
out, ok := exportBytes(vm, v)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("图片解密规则没有返回字节")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchBytes 用书源的网络栈拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
func (r *JSRunner) FetchBytes(absURL string) ([]byte, error) {
|
||||
if r.cfg.FetchBytes == nil {
|
||||
return nil, ErrJsUnsupported
|
||||
}
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.FetchBytes(absURL)
|
||||
}
|
||||
|
||||
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
|
||||
func exportValue(v goja.Value) any {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
@@ -571,6 +636,41 @@ func exportValue(v goja.Value) any {
|
||||
}
|
||||
}
|
||||
|
||||
// stripRuleJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
// 与 reader 包的 stripJSWrapper 同语义;规则包不能反向依赖 reader 包,故此处保留一份。
|
||||
func stripRuleJSWrapper(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return strings.TrimSpace(trimmed[len("@js:"):])
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// exportBytes 把 JS 返回值按字节取出:ArrayBuffer / typed array / 字符串。
|
||||
// 图片与字体解密规则返回的都是字节,ArrayBuffer 的 Export() 只给出属性 map,
|
||||
// 必须走 ExportTo 才能拿到真正的字节。
|
||||
func exportBytes(vm *goja.Runtime, v goja.Value) ([]byte, bool) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return nil, false
|
||||
}
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(v, &buf); err == nil {
|
||||
return buf, true
|
||||
}
|
||||
if s, ok := v.Export().(string); ok {
|
||||
return []byte(s), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// toJSValue 把引擎内部结果转为可注入 JS 的值。
|
||||
// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。
|
||||
func toJSValue(vm *goja.Runtime, v any) any {
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -171,6 +174,9 @@ func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
|
||||
a.bookCustom = map[string]string{}
|
||||
}
|
||||
a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "")
|
||||
if a.bookVarPutter != nil {
|
||||
a.bookVarPutter()
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
@@ -381,16 +387,183 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
|
||||
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
|
||||
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
|
||||
var jsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
mem map[string]string
|
||||
}{m: map[string]string{}, mem: map[string]string{}}
|
||||
//
|
||||
// 持久层按书源(bookSourceUrl)命名空间隔离,并落到 CacheDir/reader-js-cache:
|
||||
// 之前是包级全局 map,任何书源的 put/get 全局可见,多个源用同一个 key 会互相串值,
|
||||
// 一个源写满 4096 条还会把别的源的缓存一起清掉;重启后也全部丢失。
|
||||
|
||||
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
|
||||
// jsCacheMaxEntries 单个书源命名空间的条目上限:超了按写入时间淘汰最旧的一半,
|
||||
// 只影响本命名空间,不再波及其它书源。
|
||||
const jsCacheMaxEntries = 4096
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
// jsCacheNamespace 一个书源的持久/内存缓存命名空间。
|
||||
type jsCacheNamespace struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
seq map[string]int64
|
||||
next int64
|
||||
path string
|
||||
}
|
||||
|
||||
// jsCacheRegistry 按命名空间持有 cache,命名空间取书源 URL。
|
||||
var jsCacheRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*jsCacheNamespace
|
||||
}{m: map[string]*jsCacheNamespace{}}
|
||||
|
||||
// jsCacheFor 取(或创建)命名空间;cacheDir 非空时尝试从磁盘恢复。
|
||||
func jsCacheFor(namespace, cacheDir string) *jsCacheNamespace {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsCacheRegistry.mu.Lock()
|
||||
ns, ok := jsCacheRegistry.m[namespace]
|
||||
if !ok {
|
||||
ns = &jsCacheNamespace{data: map[string]string{}, seq: map[string]int64{}}
|
||||
if cacheDir != "" {
|
||||
ns.path = filepath.Join(cacheDir, "reader-js-cache", md5Hex(namespace, true)+".json")
|
||||
}
|
||||
ns.load()
|
||||
jsCacheRegistry.m[namespace] = ns
|
||||
}
|
||||
jsCacheRegistry.mu.Unlock()
|
||||
return ns
|
||||
}
|
||||
|
||||
// load 从磁盘恢复命名空间(仅供 jsCacheFor 在注册表锁内首次调用)。
|
||||
func (n *jsCacheNamespace) load() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(n.path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
var stored struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}
|
||||
if json.Unmarshal(raw, &stored) != nil {
|
||||
return
|
||||
}
|
||||
if stored.Data != nil {
|
||||
n.data = stored.Data
|
||||
}
|
||||
if stored.Seq != nil {
|
||||
n.seq = stored.Seq
|
||||
for _, v := range stored.Seq {
|
||||
if v > n.next {
|
||||
n.next = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// persist 把命名空间写回磁盘(临时文件 + 原子 rename)。
|
||||
func (n *jsCacheNamespace) persist() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}{n.data, n.seq})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(n.path), 0o750); err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(n.path), ".js-cache-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(name, n.path)
|
||||
}
|
||||
|
||||
// put 写一个持久键值(返回最终值)。
|
||||
func (n *jsCacheNamespace) put(key, value string) string {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if _, exists := n.data[key]; !exists && len(n.data) >= jsCacheMaxEntries {
|
||||
n.evictOldestLocked()
|
||||
}
|
||||
n.next++
|
||||
n.data[key] = value
|
||||
n.seq[key] = n.next
|
||||
n.persist()
|
||||
return value
|
||||
}
|
||||
|
||||
// evictOldestLocked 淘汰最旧的一半条目(调用方需持锁)。
|
||||
func (n *jsCacheNamespace) evictOldestLocked() {
|
||||
type entry struct {
|
||||
key string
|
||||
seq int64
|
||||
}
|
||||
entries := make([]entry, 0, len(n.data))
|
||||
for k := range n.data {
|
||||
entries = append(entries, entry{k, n.seq[k]})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].seq < entries[j].seq })
|
||||
drop := len(entries)/2 + 1
|
||||
for i := 0; i < drop && i < len(entries); i++ {
|
||||
delete(n.data, entries[i].key)
|
||||
delete(n.seq, entries[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
// get 读一个持久键。
|
||||
func (n *jsCacheNamespace) get(key string) (string, bool) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
v, ok := n.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// del 删除一个持久键。
|
||||
func (n *jsCacheNamespace) del(key string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
delete(n.data, key)
|
||||
delete(n.seq, key)
|
||||
n.persist()
|
||||
}
|
||||
|
||||
// memoryStore 进程内内存缓存(putMemory/getFromMemory),同样按命名空间隔离。
|
||||
var jsMemoryRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string
|
||||
}{m: map[string]map[string]string{}}
|
||||
|
||||
func jsMemoryFor(namespace string) map[string]string {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
defer jsMemoryRegistry.mu.Unlock()
|
||||
store, ok := jsMemoryRegistry.m[namespace]
|
||||
if !ok {
|
||||
store = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = store
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// newCacheObject 构造 JS 的 `cache` 对象。
|
||||
// namespace 取书源 URL;cacheDir 非空时 put/get 持久化到磁盘。
|
||||
func newCacheObject(vm *goja.Runtime, namespace, cacheDir string) *goja.Object {
|
||||
ns := jsCacheFor(namespace, cacheDir)
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
@@ -398,51 +571,50 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
}
|
||||
}
|
||||
|
||||
// 两套存储共用同一份读写实现,只有落点不同。
|
||||
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(*store) >= jsCacheMaxEntries {
|
||||
*store = map[string]string{}
|
||||
}
|
||||
(*store)[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := (*store)[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(ns.put(key, val))
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if v, ok := ns.get(stringArg(call, 0)); ok {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
}
|
||||
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(*store, key)
|
||||
jsCache.mu.Unlock()
|
||||
return goja.Null()
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
ns.del(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory):进程内、不落盘。
|
||||
mem := jsMemoryFor(namespace)
|
||||
set("putMemory", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
if _, exists := mem[key]; !exists && len(mem) >= jsCacheMaxEntries {
|
||||
mem = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = mem
|
||||
}
|
||||
mem[key] = val
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("getFromMemory", func(call goja.FunctionCall) goja.Value {
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
v, ok := mem[stringArg(call, 0)]
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
}
|
||||
|
||||
set("put", putTo(&jsCache.m))
|
||||
set("get", getFrom(&jsCache.m))
|
||||
set("delete", deleteFrom(&jsCache.m))
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory)
|
||||
set("putMemory", putTo(&jsCache.mem))
|
||||
set("getFromMemory", getFrom(&jsCache.mem))
|
||||
return vm.ToValue(v)
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
|
||||
@@ -51,3 +51,44 @@ func TestCacheMemoryRoundTrip(t *testing.T) {
|
||||
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// cache 必须按书源隔离:旧实现是包级全局 map,任一源的 put/get 对所有源可见,
|
||||
// 多个源用同一个 key 会互相串值,一个源写满还会清掉别的源的缓存。
|
||||
func TestCacheIsolatedPerSource(t *testing.T) {
|
||||
srcA := map[string]any{"bookSourceUrl": "https://a.example.com"}
|
||||
srcB := map[string]any{"bookSourceUrl": "https://b.example.com"}
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
runnerA := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
if _, err := runnerA.Run(ar, `cache.put('shared-key', 'from-a')`, nil, ""); err != nil {
|
||||
t.Fatalf("源 A 写入失败: %v", err)
|
||||
}
|
||||
runnerB := NewJSRunner(JSConfig{SourceProps: srcB})
|
||||
v, err := runnerB.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 B 读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("源 B 读到了源 A 的缓存: %q", got)
|
||||
}
|
||||
// 源 A 自己重开运行时仍应读到(落到 CacheDir 之外的进程内持久层)。
|
||||
runnerA2 := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
v, err = runnerA2.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 A 二次读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "from-a" {
|
||||
t.Fatalf("源 A 应读到自己的缓存: %q", got)
|
||||
}
|
||||
// 内存缓存同样按源隔离。
|
||||
if _, err := runnerA.Run(ar, `cache.putMemory('mem-key', 'a')`, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err = runnerB.Run(ar, `String(cache.getFromMemory('mem-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("内存缓存跨源串值: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions 的 queryTTF / replaceFont:
|
||||
// 解析被字体混淆的正文。书源规则通常写成
|
||||
//
|
||||
// java.replaceFont(result, java.queryTTF(errorFontUrl), java.queryTTF(correctFontUrl))
|
||||
//
|
||||
// 错误字体把码点映射到错字形、正确字体把字形映射回真实码点,按字形轮廓配对即可还原。
|
||||
|
||||
// queryTTFCacheMax 已解析字体缓存条数(对齐 legado 的 LruCache 小容量策略)。
|
||||
const queryTTFCacheMax = 32
|
||||
|
||||
var queryTTFCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*queryTTFFont
|
||||
// order 记录插入顺序,超容量时淘汰最早的一条。
|
||||
order []string
|
||||
}{m: map[string]*queryTTFFont{}}
|
||||
|
||||
// queryTTFFromBytes 解析字体并缓存(key = SHA-256)。
|
||||
func queryTTFFromBytes(data []byte, useCache bool) (*queryTTFFont, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
if useCache {
|
||||
queryTTFCache.mu.Lock()
|
||||
if f, ok := queryTTFCache.m[key]; ok {
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Unlock()
|
||||
}
|
||||
f, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !useCache {
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Lock()
|
||||
if len(queryTTFCache.order) >= queryTTFCacheMax {
|
||||
oldest := queryTTFCache.order[0]
|
||||
queryTTFCache.order = queryTTFCache.order[1:]
|
||||
delete(queryTTFCache.m, oldest)
|
||||
}
|
||||
queryTTFCache.m[key] = f
|
||||
queryTTFCache.order = append(queryTTFCache.order, key)
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// installFontBridge 注册 java.queryTTF / queryBase64TTF / replaceFont。
|
||||
// set 是 java 对象的属性写入闭包(其中已包含 bridgeErr 的异常抛出语义)。
|
||||
func (r *JSRunner) installFontBridge(vm *goja.Runtime, set func(string, any)) {
|
||||
// fontErr 把错误翻译成 JS 异常:书源常把 queryTTF 放在 try/catch 里降级。
|
||||
fontErr := func(name string, msg string) goja.Value {
|
||||
return vm.ToValue("java." + name + ": " + msg)
|
||||
}
|
||||
set("queryTTF", func(call goja.FunctionCall) goja.Value {
|
||||
useCache := true
|
||||
if len(call.Arguments) > 1 {
|
||||
useCache = call.Arguments[1].ToBoolean()
|
||||
}
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, useCache)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
// queryBase64TTF 是 legado 的旧别名,语义完全相同。
|
||||
set("queryBase64TTF", func(call goja.FunctionCall) goja.Value {
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, true)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
set("replaceFont", func(call goja.FunctionCall) goja.Value {
|
||||
text := stringArg(call, 0)
|
||||
errorFont := queryTTFFontArg(call, 1)
|
||||
correctFont := queryTTFFontArg(call, 2)
|
||||
filter := false
|
||||
if len(call.Arguments) > 3 {
|
||||
filter = call.Arguments[3].ToBoolean()
|
||||
}
|
||||
if errorFont == nil || correctFont == nil {
|
||||
return vm.ToValue(text)
|
||||
}
|
||||
return vm.ToValue(replaceFontText(text, errorFont, correctFont, filter))
|
||||
})
|
||||
}
|
||||
|
||||
// fontDataFromArg 从 JS 参数取字体字节:URL / base64 / ArrayBuffer / typed array。
|
||||
func (r *JSRunner) fontDataFromArg(call goja.FunctionCall) ([]byte, error) {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
// 字节形态(ArrayBuffer / Uint8Array)优先。
|
||||
if data, ok := exportBytes(r.vm, call.Arguments[0]); ok && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
raw := strings.TrimSpace(call.Arguments[0].String())
|
||||
if raw == "" {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
data, err := r.FetchBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("下载字体失败: " + err.Error())
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
// base64(支持 data:font/...;base64, 前缀与 URL-safe 变体)。
|
||||
if i := strings.Index(raw, "base64,"); i >= 0 {
|
||||
raw = raw[i+len("base64,"):]
|
||||
}
|
||||
data, err := base64DecodeBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("字体数据不是 base64")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// errQueryTTF 构造 queryTTF 相关错误(拼上函数名前缀由调用方负责)。
|
||||
func errQueryTTF(msg string) error { return fmt.Errorf("%s", msg) }
|
||||
|
||||
// queryTTFFontArg 把 JS 参数还原成 *queryTTFFont。
|
||||
// 书源常把 queryTTF 的结果存进变量再传给 replaceFont,goja 会原样保留 Go 指针。
|
||||
func queryTTFFontArg(call goja.FunctionCall, idx int) *queryTTFFont {
|
||||
if len(call.Arguments) <= idx {
|
||||
return nil
|
||||
}
|
||||
if f, ok := call.Arguments[idx].Export().(*queryTTFFont); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceFontText 按字形轮廓把错误字体渲染的文本还原成正确字体对应的真实文字。
|
||||
// 顺序对齐 legado JsExtensions.replaceFont:逐码点取错字形,再到正确字体查回码点。
|
||||
func replaceFontText(text string, errorFont, correctFont *queryTTFFont, filter bool) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(text))
|
||||
for _, cp := range text {
|
||||
if isBlankUnicode(cp) {
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
glyph := errorFont.unicodeToGlyph[cp]
|
||||
if _, ok := errorFont.unicodeToGlyphID[cp]; !ok {
|
||||
// 错误字体里没有这个码点(对应 legado 的 glyfId == 0 → 视为无字形)。
|
||||
glyph = ""
|
||||
}
|
||||
if glyph == "" {
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
if real, ok := correctFont.glyphToUnicode[glyph]; ok && real != 0 {
|
||||
sb.WriteRune(real)
|
||||
continue
|
||||
}
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isBlankUnicode 判断码点是否是不可见的空白(对齐 legado 的 isBlankUnicode 列表)。
|
||||
func isBlankUnicode(cp rune) bool {
|
||||
switch cp {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f', 0x00A0, 0x2000, 0x2001, 0x2002, 0x2003,
|
||||
0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029,
|
||||
0x202F, 0x205F, 0x3000, 0xFEFF:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
// 本文件对应 AnalyzeByRegex.kt。Java 正则语义用 regexp2 对齐
|
||||
// (支持前向后向断言与反向引用),匹配循环对齐 Matcher.find()。
|
||||
|
||||
// regexMatchTimeout 书源正则的匹配预算。
|
||||
//
|
||||
// regexp2 默认永不超时(DefaultMatchTimeout 是 MaxInt64),而书源正则来自
|
||||
// 不可信内容:灾难性回溯会永久占住一个 goroutine 和一颗 CPU 核。这里统一
|
||||
// 设一个短预算,语义与 ApplyUserReplace 的缺省值保持一致。
|
||||
const regexMatchTimeout = 3 * time.Second
|
||||
|
||||
// compileRegex 编译一条 Java 语义正则并设置匹配超时。
|
||||
func compileRegex(pattern string) (*regexp2.Regexp, error) {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
re.MatchTimeout = regexMatchTimeout
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// splitNotBlankAndTrim 对应 String.splitNotBlank("&&"):切分并去空白项。
|
||||
func splitNotBlankAndTrim(s, sep string) []string {
|
||||
var out []string
|
||||
@@ -27,7 +44,7 @@ func regexGetElement(res string, regs []string, index int) []string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -60,7 +77,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -95,7 +112,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
// regexReplaceAll 对应 Kotlin Regex.replace(result, replacement)
|
||||
// (Java $N 分组替换语义,regexp2 的 Replace 原生支持)。
|
||||
func regexReplaceAll(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(result, pattern, replacement)
|
||||
}
|
||||
@@ -116,14 +133,12 @@ func ApplyUserReplace(content, pattern, replacement string, isRegex bool, timeou
|
||||
if !isRegex {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
if timeoutMS > 0 {
|
||||
re.MatchTimeout = time.Duration(timeoutMS) * time.Millisecond
|
||||
} else {
|
||||
re.MatchTimeout = 3 * time.Second
|
||||
}
|
||||
out, err := re.Replace(content, replacement, 0, -1)
|
||||
if err != nil {
|
||||
@@ -160,7 +175,7 @@ func ApplyReplaceRegexString(content, replaceRegex string) string {
|
||||
// regexReplaceFirstOnFirstMatch 对应 replaceRegex 的 replaceFirst 分支:
|
||||
// 找到第一个匹配(无匹配返回 ""),在匹配文本上做首次替换。
|
||||
func regexReplaceFirstOnFirstMatch(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return replacement
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件移植 legado 的 QueryTTF:解析字体(sfnt)的 cmap / glyf / loca / maxp 表,
|
||||
// 建立「Unicode 码点 → 字形」「字形 → Unicode 码点」两张表,供
|
||||
// java.queryTTF / java.replaceFont 还原被字体混淆的正文。
|
||||
//
|
||||
// 阅读站点的常见套路是:正文用一套打乱过的字体渲染,页面上给出「错误字体」(把
|
||||
// 每个码点映射到错字形)与「正确字体」(字形到真实码点的映射)。replaceFont 按
|
||||
// 字形轮廓把错误字体里的字符逐个换成正确字体里同字形的码点,从而还原原文。
|
||||
//
|
||||
// 解析器全程做边界检查:字体字节来自书源(不可信),坏字体只应报错,不能 panic。
|
||||
|
||||
// queryTTFFont 一个已解析的字体。
|
||||
type queryTTFFont struct {
|
||||
// unicodeToGlyphID 码点 → 字形在 glyf 表里的下标。
|
||||
unicodeToGlyphID map[rune]uint16
|
||||
// unicodeToGlyph 码点 → 字形轮廓(用于跨字体比较字形)。
|
||||
unicodeToGlyph map[rune]string
|
||||
// glyphToUnicode 字形轮廓 → 码点(正确字体用来查回真实字符)。
|
||||
glyphToUnicode map[string]rune
|
||||
}
|
||||
|
||||
// sfnt 表标签。
|
||||
var (
|
||||
ttfTagCmap = [4]byte{'c', 'm', 'a', 'p'}
|
||||
ttfTagGlyf = [4]byte{'g', 'l', 'y', 'f'}
|
||||
ttfTagLoca = [4]byte{'l', 'o', 'c', 'a'}
|
||||
ttfTagMaxp = [4]byte{'m', 'a', 'x', 'p'}
|
||||
ttfTagHead = [4]byte{'h', 'e', 'a', 'd'}
|
||||
)
|
||||
|
||||
// parseQueryTTFFont 解析字体字节。支持 sfnt(TTF/OTF)与 ttc 的第一套字体。
|
||||
func parseQueryTTFFont(data []byte) (font *queryTTFFont, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
font, err = nil, fmt.Errorf("字体解析失败: %v", r)
|
||||
}
|
||||
}()
|
||||
if len(data) < 12 {
|
||||
return nil, fmt.Errorf("字体数据过短")
|
||||
}
|
||||
// ttc:取第一套字体的偏移。
|
||||
if string(data[:4]) == "ttcf" {
|
||||
if len(data) < 16 {
|
||||
return nil, fmt.Errorf("ttc 头部不完整")
|
||||
}
|
||||
off := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
if off <= 0 || off >= len(data) {
|
||||
return nil, fmt.Errorf("ttc 字体偏移非法")
|
||||
}
|
||||
data = data[off:]
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
if numTables <= 0 || 12+numTables*16 > len(data) {
|
||||
return nil, fmt.Errorf("sfnt 表目录非法")
|
||||
}
|
||||
tables := map[[4]byte][]byte{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := data[12+i*16 : 12+i*16+16]
|
||||
var tag [4]byte
|
||||
copy(tag[:], rec[:4])
|
||||
off := int(binary.BigEndian.Uint32(rec[8:12]))
|
||||
length := int(binary.BigEndian.Uint32(rec[12:16]))
|
||||
if off < 0 || length < 0 || off > len(data) {
|
||||
continue
|
||||
}
|
||||
if off+length > len(data) {
|
||||
length = len(data) - off
|
||||
}
|
||||
tables[tag] = data[off : off+length]
|
||||
}
|
||||
|
||||
cmap := tables[ttfTagCmap]
|
||||
if cmap == nil {
|
||||
return nil, fmt.Errorf("字体缺少 cmap 表")
|
||||
}
|
||||
mapping, err := parseTTFCmap(cmap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &queryTTFFont{
|
||||
unicodeToGlyphID: mapping,
|
||||
unicodeToGlyph: map[rune]string{},
|
||||
glyphToUnicode: map[string]rune{},
|
||||
}
|
||||
|
||||
// 有 glyf + loca + head + maxp 才能算字形轮廓;缺了(如 CFF 字体)只保留码点映射。
|
||||
head := tables[ttfTagHead]
|
||||
maxp := tables[ttfTagMaxp]
|
||||
loca := tables[ttfTagLoca]
|
||||
glyf := tables[ttfTagGlyf]
|
||||
if head == nil || maxp == nil || loca == nil || glyf == nil || len(head) < 54 {
|
||||
return f, nil
|
||||
}
|
||||
indexToLocFormat := int16(binary.BigEndian.Uint16(head[50:52]))
|
||||
numGlyphs := int(binary.BigEndian.Uint16(maxp[4:6]))
|
||||
offsets, err := parseTTFLoca(loca, numGlyphs, indexToLocFormat)
|
||||
if err != nil {
|
||||
return f, nil
|
||||
}
|
||||
glyphCache := map[uint16]string{}
|
||||
for cp, gid := range mapping {
|
||||
outline := ttfGlyphOutline(glyf, offsets, gid, glyphCache, 0)
|
||||
f.unicodeToGlyph[cp] = outline
|
||||
if _, exists := f.glyphToUnicode[outline]; !exists {
|
||||
f.glyphToUnicode[outline] = cp
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// parseTTFCmap 解析 cmap 表,返回码点 → 字形下标。
|
||||
// 支持 format 0 / 4 / 6(legado QueryTTF 同样只支持这三种)。
|
||||
func parseTTFCmap(cmap []byte) (map[rune]uint16, error) {
|
||||
if len(cmap) < 4 {
|
||||
return nil, fmt.Errorf("cmap 表过短")
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
|
||||
if 4+numTables*8 > len(cmap) {
|
||||
return nil, fmt.Errorf("cmap 子表目录非法")
|
||||
}
|
||||
out := map[rune]uint16{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := cmap[4+i*8 : 4+i*8+8]
|
||||
off := int(binary.BigEndian.Uint32(rec[4:8]))
|
||||
if off < 0 || off+2 > len(cmap) {
|
||||
continue
|
||||
}
|
||||
sub := cmap[off:]
|
||||
switch binary.BigEndian.Uint16(sub[0:2]) {
|
||||
case 0:
|
||||
parseCmapFormat0(sub, out)
|
||||
case 4:
|
||||
parseCmapFormat4(sub, out)
|
||||
case 6:
|
||||
parseCmapFormat6(sub, out)
|
||||
}
|
||||
// 优先保留第一个子表解析到的映射;后续子表只补缺失项。
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("cmap 没有可用的 format 0/4/6 子表")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCmapFormat0(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 262 {
|
||||
return
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(sub[2:4]))
|
||||
if length > len(sub) {
|
||||
length = len(sub)
|
||||
}
|
||||
glyphs := sub[6:min(6+256, length)]
|
||||
for i, gid := range glyphs {
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(i)]; !exists {
|
||||
out[rune(i)] = uint16(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat4(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 14 {
|
||||
return
|
||||
}
|
||||
segCountX2 := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
segCount := segCountX2 / 2
|
||||
if segCount == 0 {
|
||||
return
|
||||
}
|
||||
endBase := 14
|
||||
startBase := endBase + segCountX2 + 2
|
||||
deltaBase := startBase + segCountX2
|
||||
rangeBase := deltaBase + segCountX2
|
||||
if rangeBase+segCountX2 > len(sub) {
|
||||
return
|
||||
}
|
||||
for i := 0; i < segCount; i++ {
|
||||
end := int(binary.BigEndian.Uint16(sub[endBase+i*2:]))
|
||||
start := int(binary.BigEndian.Uint16(sub[startBase+i*2:]))
|
||||
delta := int16(binary.BigEndian.Uint16(sub[deltaBase+i*2:]))
|
||||
rangeOffset := int(binary.BigEndian.Uint16(sub[rangeBase+i*2:]))
|
||||
if start > end {
|
||||
continue
|
||||
}
|
||||
for cp := start; cp <= end && cp <= 0xFFFF; cp++ {
|
||||
if cp == 0xFFFF {
|
||||
continue
|
||||
}
|
||||
var gid uint16
|
||||
if rangeOffset == 0 {
|
||||
gid = uint16(int(cp) + int(delta))
|
||||
} else {
|
||||
idx := rangeBase + i*2 + rangeOffset + (cp-start)*2
|
||||
if idx+2 > len(sub) {
|
||||
continue
|
||||
}
|
||||
g := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if g == 0 {
|
||||
continue
|
||||
}
|
||||
gid = uint16(int(g) + int(delta))
|
||||
}
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(cp)]; !exists {
|
||||
out[rune(cp)] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat6(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 10 {
|
||||
return
|
||||
}
|
||||
first := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
count := int(binary.BigEndian.Uint16(sub[8:10]))
|
||||
for i := 0; i < count; i++ {
|
||||
idx := 10 + i*2
|
||||
if idx+2 > len(sub) {
|
||||
return
|
||||
}
|
||||
gid := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if gid == 0 {
|
||||
continue
|
||||
}
|
||||
cp := rune(first + i)
|
||||
if _, exists := out[cp]; !exists {
|
||||
out[cp] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseTTFLoca 解析 loca 表,返回每个字形的字节区间起止。
|
||||
func parseTTFLoca(loca []byte, numGlyphs int, indexToLocFormat int16) ([]int, error) {
|
||||
if indexToLocFormat == 0 {
|
||||
need := (numGlyphs + 1) * 2
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint16(loca[i*2:])) * 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
need := (numGlyphs + 1) * 4
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint32(loca[i*4:]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ttfGlyphOutline 把字形转成轮廓字符串(对应 legado QueryTTF.Glyf.toString)。
|
||||
// 复合字形递归展开组件,深度上限防自引用。
|
||||
func ttfGlyphOutline(glyf []byte, offsets []int, gid uint16, cache map[uint16]string, depth int) string {
|
||||
if depth > 8 {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
if v, ok := cache[gid]; ok {
|
||||
return v
|
||||
}
|
||||
if int(gid)+1 >= len(offsets) {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
start, end := offsets[gid], offsets[gid+1]
|
||||
if start < 0 || end > len(glyf) || end <= start {
|
||||
// 空字形(如空格):用下标本身当轮廓,保证不同码点不会互相误判。
|
||||
out := fmt.Sprintf("glyph%d", gid)
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
numberOfContours := int16(binary.BigEndian.Uint16(glyf[start : start+2]))
|
||||
if numberOfContours >= 0 {
|
||||
out := fmt.Sprintf("simple:%d:%s", numberOfContours, ttfSimpleGlyphPoints(glyf[start:end]))
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
// 复合字形:逐组件展开。
|
||||
var sb strings.Builder
|
||||
sb.WriteString("composite")
|
||||
pos := start + 10
|
||||
for pos+4 <= end {
|
||||
flags := binary.BigEndian.Uint16(glyf[pos : pos+2])
|
||||
component := binary.BigEndian.Uint16(glyf[pos+2 : pos+4])
|
||||
pos += 4
|
||||
if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS
|
||||
pos += 4
|
||||
} else {
|
||||
pos += 2
|
||||
}
|
||||
switch {
|
||||
case flags&0x0008 != 0: // WE_HAVE_A_SCALE
|
||||
pos += 2
|
||||
case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE
|
||||
pos += 4
|
||||
case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO
|
||||
pos += 8
|
||||
}
|
||||
sb.WriteString("+")
|
||||
sb.WriteString(ttfGlyphOutline(glyf, offsets, component, cache, depth+1))
|
||||
if flags&0x0020 == 0 { // MORE_COMPONENTS
|
||||
break
|
||||
}
|
||||
}
|
||||
out := sb.String()
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
|
||||
// ttfSimpleGlyphPoints 取简单字形的轮廓点(标志与坐标的紧凑编码)。
|
||||
func ttfSimpleGlyphPoints(data []byte) string {
|
||||
if len(data) < 10 {
|
||||
return ""
|
||||
}
|
||||
numberOfContours := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
if numberOfContours <= 0 {
|
||||
return ""
|
||||
}
|
||||
endPtsPos := 10
|
||||
if endPtsPos+numberOfContours*2+2 > len(data) {
|
||||
return ""
|
||||
}
|
||||
numPoints := int(binary.BigEndian.Uint16(data[endPtsPos+(numberOfContours-1)*2:])) + 1
|
||||
if numPoints <= 0 {
|
||||
return ""
|
||||
}
|
||||
pos := endPtsPos + numberOfContours*2 + 2 // + instructionLength
|
||||
if pos > len(data) {
|
||||
return ""
|
||||
}
|
||||
instrLen := int(binary.BigEndian.Uint16(data[pos-2 : pos]))
|
||||
pos += instrLen
|
||||
flags := make([]byte, 0, numPoints)
|
||||
for len(flags) < numPoints && pos < len(data) {
|
||||
flag := data[pos]
|
||||
pos++
|
||||
flags = append(flags, flag)
|
||||
if flag&0x08 != 0 { // REPEAT
|
||||
if pos >= len(data) {
|
||||
break
|
||||
}
|
||||
repeat := int(data[pos])
|
||||
pos++
|
||||
for i := 0; i < repeat && len(flags) < numPoints; i++ {
|
||||
flags = append(flags, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 解析 x / y 坐标(与点数等长的增量序列,这里只用于区分字形)。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("n=%d;", numPoints))
|
||||
for _, flag := range flags {
|
||||
sb.WriteByte('0' + flag&0x0F)
|
||||
}
|
||||
sb.WriteString(";")
|
||||
// 跳过坐标数据不影响「同名轮廓一致性」的判断:同字形的字体坐标编码一致。
|
||||
if pos < len(data) {
|
||||
sb.WriteString(fmt.Sprintf("d=%d", len(data)-pos))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 字体混淆还原(ttf.go / queryttf.go)的测试:用合成的 TTF 覆盖 cmap format 0/4、
|
||||
// loca 短格式、简单/复合字形,验证「错误字体 → 正确字体」的按字形还原。
|
||||
|
||||
// buildTestTTF 构造一个最小可解析的 TTF:
|
||||
// cmap(format 4)把给定码点映射到指定字形下标,glyf 里每个字形一个方框。
|
||||
func buildTestTTF(t *testing.T, entries map[rune]uint16) []byte {
|
||||
t.Helper()
|
||||
const numGlyphs = 8
|
||||
// 每个字形一个简单方框:轮廓数 1,1 个点。
|
||||
glyph := func() []byte {
|
||||
g := make([]byte, 0, 20)
|
||||
g = append(g, 0x00, 0x01) // numberOfContours = 1
|
||||
g = append(g, 0, 0, 0, 0, 0, 0, 0, 0) // bbox
|
||||
g = append(g, 0x00, 0x00) // endPtsOfContours[0] = 0
|
||||
g = append(g, 0x00, 0x00) // instructionLength = 0
|
||||
g = append(g, 0x01, 0x01) // flag: on-curve | x-short | y-short
|
||||
g = append(g, 0x00, 0x00) // x=0, y=0
|
||||
return g
|
||||
}
|
||||
glyfData := make([]byte, 0, 128)
|
||||
offsets := make([]int, 0, numGlyphs+1)
|
||||
for i := 0; i < numGlyphs; i++ {
|
||||
offsets = append(offsets, len(glyfData))
|
||||
if i == 0 {
|
||||
continue // .notdef 空字形
|
||||
}
|
||||
g := glyph()
|
||||
// 让不同字形的字节长度不同,轮廓字符串才能区分。
|
||||
for j := 0; j < i-1; j++ {
|
||||
g = append(g, 0x01, 0x00, 0x00) // 额外点
|
||||
}
|
||||
glyfData = append(glyfData, g...)
|
||||
}
|
||||
offsets = append(offsets, len(glyfData))
|
||||
|
||||
// loca(短格式,偏移/2)
|
||||
loca := make([]byte, (numGlyphs+1)*2)
|
||||
for i, off := range offsets {
|
||||
binary.BigEndian.PutUint16(loca[i*2:], uint16(off/2))
|
||||
}
|
||||
|
||||
// cmap:format 4 单段 + 结束段
|
||||
var cmap bytes.Buffer
|
||||
codes := make([]rune, 0, len(entries))
|
||||
for cp := range entries {
|
||||
codes = append(codes, cp)
|
||||
}
|
||||
// 按码点排序,构造连续单点段。
|
||||
for i := 0; i < len(codes); i++ {
|
||||
for j := i + 1; j < len(codes); j++ {
|
||||
if codes[j] < codes[i] {
|
||||
codes[i], codes[j] = codes[j], codes[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
segCount := len(codes) + 1
|
||||
endCodes := make([]uint16, 0, segCount)
|
||||
startCodes := make([]uint16, 0, segCount)
|
||||
idDeltas := make([]uint16, 0, segCount)
|
||||
for _, cp := range codes {
|
||||
endCodes = append(endCodes, uint16(cp))
|
||||
startCodes = append(startCodes, uint16(cp))
|
||||
idDeltas = append(idDeltas, uint16(int(entries[cp])-int(cp)))
|
||||
}
|
||||
endCodes = append(endCodes, 0xFFFF)
|
||||
startCodes = append(startCodes, 0xFFFF)
|
||||
idDeltas = append(idDeltas, 1)
|
||||
rangeOffsets := make([]uint16, segCount) // 全 0:用 idDelta
|
||||
|
||||
sub := new(bytes.Buffer)
|
||||
writeU16 := func(v uint16) { _ = binary.Write(sub, binary.BigEndian, v) }
|
||||
length := 16 + segCount*8
|
||||
writeU16(4) // format
|
||||
writeU16(uint16(length)) // length
|
||||
writeU16(0) // language
|
||||
writeU16(uint16(segCount * 2)) // segCountX2
|
||||
writeU16(0) // searchRange(解析器不校验)
|
||||
writeU16(0) // entrySelector
|
||||
writeU16(0) // rangeShift
|
||||
for _, v := range endCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
writeU16(0) // reservedPad
|
||||
for _, v := range startCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range idDeltas {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range rangeOffsets {
|
||||
writeU16(v)
|
||||
}
|
||||
subBytes := sub.Bytes()
|
||||
|
||||
// cmap 头 + 一个子表记录
|
||||
cmap.Write([]byte{0, 0})
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1))
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(3)) // platformID = Windows
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1)) // encodingID = Unicode BMP
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint32(12))
|
||||
cmap.Write(subBytes)
|
||||
|
||||
head := make([]byte, 54)
|
||||
binary.BigEndian.PutUint16(head[50:], 0) // indexToLocFormat = 0(短 loca)
|
||||
maxp := make([]byte, 6)
|
||||
binary.BigEndian.PutUint16(maxp[4:], numGlyphs)
|
||||
|
||||
tables := []struct {
|
||||
tag string
|
||||
data []byte
|
||||
}{
|
||||
{"cmap", cmap.Bytes()},
|
||||
{"glyf", glyfData},
|
||||
{"loca", loca},
|
||||
{"head", head},
|
||||
{"maxp", maxp},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0x00, 0x01, 0x00, 0x00}) // sfntVersion
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(len(tables)))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
offset := 12 + len(tables)*16
|
||||
offsetsTable := make([]int, len(tables))
|
||||
for i, tb := range tables {
|
||||
padded := tb.data
|
||||
if len(padded)%4 != 0 {
|
||||
padded = append(padded, make([]byte, 4-len(padded)%4)...)
|
||||
}
|
||||
offsetsTable[i] = offset
|
||||
offset += len(padded)
|
||||
}
|
||||
for i, tb := range tables {
|
||||
out.WriteString(tb.tag)
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(offsetsTable[i]))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(len(tb.data)))
|
||||
}
|
||||
for _, tb := range tables {
|
||||
out.Write(tb.data)
|
||||
for out.Len()%4 != 0 {
|
||||
out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// 解析出的码点 → 字形 → 码点映射与构造时一致。
|
||||
func TestQueryTTFParseRoundTrip(t *testing.T) {
|
||||
data := buildTestTTF(t, map[rune]uint16{
|
||||
'A': 1, 'B': 2, 'C': 3, 'D': 4,
|
||||
})
|
||||
font, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解析字体失败: %v", err)
|
||||
}
|
||||
for cp, gid := range map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4} {
|
||||
if got := font.unicodeToGlyphID[cp]; got != gid {
|
||||
t.Fatalf("码点 %q 的字形下标 = %d,期望 %d", cp, got, gid)
|
||||
}
|
||||
}
|
||||
if font.glyphToUnicode[font.unicodeToGlyph['A']] != 'A' {
|
||||
t.Fatal("字形 → 码点映射不正确")
|
||||
}
|
||||
if font.unicodeToGlyph['A'] == font.unicodeToGlyph['B'] {
|
||||
t.Fatal("不同码点的轮廓不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// replaceFont:错误字体把 A 渲染成 B 的字形,正确字体应把 A 还原成 B。
|
||||
func TestReplaceFontRestoresText(t *testing.T) {
|
||||
// 错误字体:码点 A 指向字形 2(也就是 B 的形状)。
|
||||
errorFontData := buildTestTTF(t, map[rune]uint16{'A': 2, 'B': 3, 'C': 4, 'D': 5})
|
||||
// 正确字体:码点 B 指向字形 2。
|
||||
correctFontData := buildTestTTF(t, map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4})
|
||||
errorFont, err := parseQueryTTFFont(errorFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
correctFont, err := parseQueryTTFFont(correctFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 页面上写的是 'A',实际字形是 B → 应还原为 'B'。
|
||||
got := replaceFontText("A", errorFont, correctFont, false)
|
||||
if got != "B" {
|
||||
t.Fatalf("replaceFont 还原结果 = %q,期望 %q", got, "B")
|
||||
}
|
||||
// 空白与未知码点保持原样。
|
||||
mixed := replaceFontText("A 中", errorFont, correctFont, false)
|
||||
if !strings.HasPrefix(mixed, "B ") || !strings.HasSuffix(mixed, "中") {
|
||||
t.Fatalf("混合文本处理异常: %q", mixed)
|
||||
}
|
||||
// filter=true 时删掉没有对应字形的字符。
|
||||
filtered := replaceFontText("A中", errorFont, correctFont, true)
|
||||
if filtered != "B" {
|
||||
t.Fatalf("filter 结果 = %q,期望 %q", filtered, "B")
|
||||
}
|
||||
}
|
||||
|
||||
// 坏字体只应报错,不能 panic。
|
||||
func TestQueryTTFBadFontNoPanic(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
[]byte("not a font"),
|
||||
[]byte("ttcf"),
|
||||
append([]byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x02}, make([]byte, 40)...),
|
||||
}
|
||||
for i, data := range cases {
|
||||
if _, err := parseQueryTTFFont(data); err == nil {
|
||||
t.Fatalf("坏字体 #%d 应返回错误", i)
|
||||
}
|
||||
}
|
||||
// 截断的合法字体也不能 panic。
|
||||
full := buildTestTTF(t, map[rune]uint16{'A': 1})
|
||||
for cut := 1; cut < len(full); cut += 37 {
|
||||
_, _ = parseQueryTTFFont(full[:cut])
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,58 @@ func findParamSplit(s string) (start, end int, ok bool) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// FindParamSplit 导出 findParamSplit:图片等媒体地址也支持 ",{...}" 选项段,
|
||||
// 服务层需要先拆分再分别做请求与展示处理。
|
||||
func FindParamSplit(s string) (start, end int, ok bool) {
|
||||
return findParamSplit(s)
|
||||
}
|
||||
|
||||
// MediaOptions 是媒体地址尾部 ",{...}" 选项段里服务端允许应用的部分。
|
||||
//
|
||||
// 图片/音频等媒体请求走代理,选项里只有请求头与重试这类「取图必需」的字段有意义;
|
||||
// webView/webJs/js/bodyJs 这些需要浏览器或脚本执行引擎的字段必须忽略,
|
||||
// 否则一个书源就能让媒体代理变成任意请求的中转。
|
||||
type MediaOptions struct {
|
||||
Headers map[string]string
|
||||
Charset string
|
||||
Retry *int
|
||||
Method string
|
||||
}
|
||||
|
||||
// ParseMediaOptions 拆分媒体地址与其尾部选项。
|
||||
// 返回去掉选项段的地址(已 trim)、应用后的选项与「是否带选项」。
|
||||
func ParseMediaOptions(raw string) (string, MediaOptions, bool) {
|
||||
var out MediaOptions
|
||||
s := strings.TrimSpace(raw)
|
||||
st, end, ok := findParamSplit(s)
|
||||
if !ok {
|
||||
return s, out, false
|
||||
}
|
||||
base := strings.TrimSpace(s[:st])
|
||||
optionStr := strings.TrimSpace(s[end:])
|
||||
var option URLOption
|
||||
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
|
||||
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
|
||||
return base, out, false
|
||||
}
|
||||
}
|
||||
if len(option.Headers) > 0 {
|
||||
out.Headers = make(map[string]string, len(option.Headers))
|
||||
for k, v := range option.Headers {
|
||||
out.Headers[k] = anyToString(v)
|
||||
}
|
||||
}
|
||||
out.Charset = option.Charset
|
||||
out.Retry = option.Retry
|
||||
switch strings.ToUpper(option.Method) {
|
||||
case "HEAD":
|
||||
out.Method = "HEAD"
|
||||
default:
|
||||
out.Method = "GET"
|
||||
}
|
||||
return base, out, true
|
||||
}
|
||||
|
||||
func isSpaceByte(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user