refactor(色彩管理): 统一色彩与四色保真
build.yaml / build (push) Successful in 10m5s

This commit is contained in:
xiaoqidun committed 2026-10-16 00:05:46 +08:00
1 parent 5e0bca66e3
commit b5a8c9af9c
24 files changed
+935 -223

No files matched your search

+321
View File
@@ -0,0 +1,321 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"compress/zlib"
"context"
"encoding/binary"
"fmt"
"hash/crc32"
"image"
"image/color"
"io"
"math"
"github.com/xiaoqidun/pdfgo"
)
// colorImage 保留源采样及独立的十六位显示像素
type colorImage struct {
*image.NRGBA64
source image.Image
}
// pngColor 保存PNG校准空间及缺省sRGB传递函数
type pngColor struct {
space *pdfgo.ColorSpace
linearSRGB bool
}
// pngColorProfile 读取PNG颜色元数据,ICC优先于sRGB及独立校准参数
// 入参: ctx 取消上下文, data 原始编码
// 返回: []byte ICC编码, *pngColor 校准参数, error 格式或配置错误
func pngColorProfile(ctx context.Context, data []byte) ([]byte, *pngColor, error) {
const profileLimit = 16 << 20
chunks := make(map[string][]byte)
for at := 8; at < len(data); {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
if len(data)-at < 8 {
return nil, nil, fmt.Errorf("truncated PNG chunk")
}
kind := string(data[at+4 : at+8])
if kind == "IDAT" || kind == "PLTE" {
break
}
if len(data)-at < 12 {
return nil, nil, fmt.Errorf("truncated PNG chunk")
}
size := uint64(binary.BigEndian.Uint32(data[at:]))
if size > uint64(len(data)-at-12) {
return nil, nil, fmt.Errorf("invalid PNG chunk length")
}
end := at + 8 + int(size)
switch kind {
case "iCCP", "sRGB", "gAMA", "cHRM":
if _, ok := chunks[kind]; ok {
return nil, nil, fmt.Errorf("invalid PNG %s chunk order", kind)
}
if crc32.ChecksumIEEE(data[at+4:end]) != binary.BigEndian.Uint32(data[end:]) {
return nil, nil, fmt.Errorf("invalid PNG %s checksum", kind)
}
chunks[kind] = data[at+8 : end]
}
at = end + 4
if kind == "IEND" {
break
}
}
if chunk, ok := chunks["iCCP"]; ok {
name := bytes.IndexByte(chunk, 0)
if name < 1 || name > 79 || len(chunk) < name+3 || chunk[name+1] != 0 {
return nil, nil, fmt.Errorf("invalid PNG ICC header")
}
for i, c := range chunk[:name] {
if c < 32 || c > 126 && c < 161 || c == 32 && (i == 0 || i == name-1 || chunk[i-1] == 32) {
return nil, nil, fmt.Errorf("invalid PNG ICC name")
}
}
input := bytes.NewReader(chunk[name+2:])
reader, err := zlib.NewReader(input)
if err != nil {
return nil, nil, err
}
profile, err := io.ReadAll(io.LimitReader(contextInput{ReadCloser: reader, context: ctx}, profileLimit+1))
closeErr := reader.Close()
if err != nil {
return nil, nil, err
}
if closeErr != nil {
return nil, nil, closeErr
}
if len(profile) > profileLimit {
return nil, nil, fmt.Errorf("PNG ICC profile exceeds size limit")
}
if input.Len() != 0 {
return nil, nil, fmt.Errorf("trailing PNG ICC data")
}
kind := "RGB "
if len(data) > 25 && (data[25] == 0 || data[25] == 4) {
kind = "GRAY"
}
if len(profile) < 132 || string(profile[16:20]) != kind {
return nil, nil, fmt.Errorf("ICC source model does not match PNG color space %q", kind)
}
return profile, nil, nil
}
if chunk, ok := chunks["sRGB"]; ok {
if len(chunk) != 1 || chunk[0] > 3 {
return nil, nil, fmt.Errorf("invalid PNG sRGB intent")
}
return nil, nil, nil
}
if len(chunks) == 0 {
return nil, nil, nil
}
coordinates := [8]float64{0.3127, 0.3290, 0.6400, 0.3300, 0.3000, 0.6000, 0.1500, 0.0600}
if chunk, ok := chunks["cHRM"]; ok {
if len(chunk) != 32 {
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
}
for i := range coordinates {
coordinates[i] = float64(binary.BigEndian.Uint32(chunk[i*4:])) / 100000
}
}
var xyz [4][3]float64
for i := range xyz {
x, y := coordinates[2*i], coordinates[2*i+1]
if y <= 0 || x < 0 || x+y > 1 {
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
}
xyz[i] = [3]float64{x / y, 1, (1 - x - y) / y}
}
white := xyz[0]
det := func(a, b, c [3]float64) float64 {
return a[0]*(b[1]*c[2]-b[2]*c[1]) - b[0]*(a[1]*c[2]-a[2]*c[1]) + c[0]*(a[1]*b[2]-a[2]*b[1])
}
d := det(xyz[1], xyz[2], xyz[3])
if math.Abs(d) < 1e-12 {
return nil, nil, fmt.Errorf("degenerate PNG chromaticities")
}
scale := [3]float64{det(white, xyz[2], xyz[3]) / d, det(xyz[1], white, xyz[3]) / d, det(xyz[1], xyz[2], white) / d}
var matrix [9]float64
for i := range 3 {
for j := range 3 {
matrix[3*i+j] = xyz[i+1][j] * scale[i]
}
}
gamma := [3]float64{1, 1, 1}
linear := true
if chunk, ok := chunks["gAMA"]; ok {
if len(chunk) != 4 || binary.BigEndian.Uint32(chunk) == 0 {
return nil, nil, fmt.Errorf("invalid PNG gamma")
}
g := 100000 / float64(binary.BigEndian.Uint32(chunk))
gamma = [3]float64{g, g, g}
linear = false
}
space, err := pdfgo.NewCalRGBColorSpace(white, [3]float64{}, gamma, matrix)
if err != nil {
return nil, nil, err
}
return nil, &pngColor{space: space, linearSRGB: linear}, nil
}
// jpegColorProfile 读取JPEG分段ICC配置,拒绝缺段、重复及模型不匹配
// 入参: ctx 取消上下文, data JPEG编码, model 源颜色模型
// 返回: []byte 独立配置编码,未声明时为nil, error 配置或取消错误
func jpegColorProfile(ctx context.Context, data []byte, model color.Model) ([]byte, error) {
var parts [255][]byte
count, found, total := 0, 0, 0
for at := 2; at < len(data); {
if err := ctx.Err(); err != nil {
return nil, err
}
if data[at] != 0xff {
return nil, fmt.Errorf("invalid JPEG marker")
}
for at < len(data) && data[at] == 0xff {
at++
}
if at == len(data) {
return nil, fmt.Errorf("truncated JPEG marker")
}
marker := data[at]
at++
if marker == 0xda || marker == 0xd9 {
break
}
if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 {
continue
}
if len(data)-at < 2 {
return nil, fmt.Errorf("truncated JPEG segment")
}
size := int(binary.BigEndian.Uint16(data[at:]))
if size < 2 || size > len(data)-at {
return nil, fmt.Errorf("invalid JPEG segment length")
}
segment := data[at+2 : at+size]
at += size
if marker != 0xe2 || !bytes.HasPrefix(segment, []byte("ICC_PROFILE\x00")) {
continue
}
if len(segment) <= 14 {
return nil, fmt.Errorf("truncated JPEG color profile")
}
index, n := int(segment[12]), int(segment[13])
if index == 0 || index > n || count != 0 && count != n || parts[index-1] != nil {
return nil, fmt.Errorf("invalid JPEG color profile sequence")
}
count, found = n, found+1
parts[index-1] = segment[14:]
total += len(segment) - 14
}
if found == 0 {
return nil, nil
}
if found != count {
return nil, fmt.Errorf("incomplete JPEG color profile")
}
profile := make([]byte, 0, total)
for _, part := range parts[:count] {
profile = append(profile, part...)
}
kind := "RGB "
if model == color.CMYKModel {
kind = "CMYK"
} else if model == color.GrayModel {
kind = "GRAY"
}
if len(profile) < 132 || string(profile[16:20]) != kind {
return nil, fmt.Errorf("ICC source model does not match JPEG color space %q", kind)
}
return profile, nil
}
// jpegDisplayImage 统一JPEG显示变换,保留源分量并使用文档内嵌ICC
// 入参: ctx 取消上下文, source 源像素, profile 颜色配置,可为nil
// 返回: image.Image 显示像素, error 变换或取消错误
func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
cmyk, ok := source.(*image.CMYK)
if !ok {
if profile != nil {
return applyTIFFColorProfile(ctx, source, profile)
}
return source, nil
}
if profile == nil {
profile = &pdfgo.ColorSpace{Model: "DeviceCMYK"}
}
bounds := cmyk.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > math.MaxInt/8/bounds.Dy() {
return nil, fmt.Errorf("JPEG color image dimensions exceed capacity")
}
pixels := image.NewNRGBA64(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
ink := cmyk.CMYKAt(x, y)
values := [4]float64{float64(ink.C) / 255, float64(ink.M) / 255, float64(ink.Y) / 255, float64(ink.K) / 255}
rgb, err := profile.SRGB(values[:], profile.DefaultIntent())
if err != nil {
return nil, err
}
pixels.SetNRGBA64(x, y, color.NRGBA64{R: colorWord(rgb[0]), G: colorWord(rgb[1]), B: colorWord(rgb[2]), A: 65535})
}
}
return &colorImage{NRGBA64: pixels, source: source}, nil
}
// colorWord 将归一化颜色舍入为十六位分量
// 入参: value 颜色分量
// 返回: uint16 十六位分量
func colorWord(value float64) uint16 {
return uint16(math.Round(max(0, min(1, value)) * 65535))
}
// cmykGrayAt 从原始四色采样计算蒙版灰度,不应用显示配置
// 入参: x 横坐标, y 纵坐标
// 返回: color.Gray 蒙版灰度
func (p *planarTIFFImage) cmykGrayAt(x, y int) color.Gray {
if !image.Pt(x, y).In(p.bounds) {
return color.Gray{}
}
ink := p.inksAt(x, y)
maximum := uint32(65535)
if p.alpha == 1 {
maximum = uint32(ink[4])
if maximum == 0 {
return color.Gray{}
}
}
black := maximum - min(uint32(ink[3]), maximum)
var rgb [3]uint32
for i := range rgb {
rgb[i] = (maximum - min(uint32(ink[i]), maximum)) * black / maximum
if p.alpha != 1 {
rgb[i] = rgb[i] * uint32(ink[4]) / 65535
}
}
return color.Gray{Y: uint8((19595*rgb[0] + 38470*rgb[1] + 7471*rgb[2] + 1<<15) >> 24)}
}
+43 -9
View File
@@ -26,17 +26,21 @@ import (
"io"
"sync"
"sync/atomic"
"github.com/xiaoqidun/pdfgo"
)
// EncodedImage 保留PNG或JPEG原始编码,首次读取像素时解码
// Bytes返回只读数据,Image返回共享只读像素,解码错误由Image返回
type EncodedImage struct {
data []byte
format string
config image.Config
result atomic.Pointer[encodedImageResult]
mu sync.Mutex
pending chan struct{}
data []byte
format string
config image.Config
profile []byte
pngColor *pngColor
result atomic.Pointer[encodedImageResult]
mu sync.Mutex
pending chan struct{}
}
// encodedImageResult 保存已完成的像素解码结果,不保存取消或超时错误
@@ -65,9 +69,14 @@ func (img *EncodedImage) Bytes() []byte { return img.data }
// 返回: string 媒体类型
func (img *EncodedImage) MIME() string { return "image/" + img.format }
// ColorModel 返回头信息中的颜色模型,不解码像素
// ColorModel 返回显示像素的颜色模型,不解码像素
// 返回: color.Model 颜色模型
func (img *EncodedImage) ColorModel() color.Model { return img.config.ColorModel }
func (img *EncodedImage) ColorModel() color.Model {
if img.profile != nil || img.pngColor != nil || img.config.ColorModel == color.CMYKModel {
return color.NRGBA64Model
}
return img.config.ColorModel
}
// Bounds 返回图片范围,不解码像素
// 返回: image.Rectangle 图片范围
@@ -131,6 +140,18 @@ func (img *EncodedImage) decodeContext(ctx context.Context, pending chan struct{
img.mu.Unlock()
}()
pixels, _, err := image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(img.data)), context: ctx})
if err == nil && (img.format == "jpeg" || img.profile != nil) {
var profile *pdfgo.ColorSpace
if img.profile != nil {
profile, err = pdfgo.NewICCColorSpace(img.profile)
}
if err == nil {
pixels, err = jpegDisplayImage(ctx, pixels, profile)
}
}
if err == nil && img.pngColor != nil {
pixels, err = applyImageColorProfile(ctx, pixels, img.pngColor.space, img.pngColor.linearSRGB)
}
if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr
}
@@ -173,5 +194,18 @@ func newEncodedImageContext(ctx context.Context, data []byte) (*EncodedImage, er
if format != "jpeg" && format != "png" {
return nil, fmt.Errorf("unsupported encoded image %q", format)
}
return &EncodedImage{data: data, format: format, config: config}, nil
img := &EncodedImage{data: data, format: format, config: config}
if format == "jpeg" {
img.profile, err = jpegColorProfile(ctx, data, config.ColorModel)
if err != nil {
return nil, err
}
}
if format == "png" {
img.profile, img.pngColor, err = pngColorProfile(ctx, data)
if err != nil {
return nil, err
}
}
return img, nil
}
+47
View File
@@ -441,6 +441,46 @@ func PixelDataContext(ctx context.Context, img image.Image) (image.Image, error)
// 入参: img 图片对象, x X坐标, y Y坐标
// 返回: color.Gray 灰度像素
func GrayAt(img image.Image, x, y int) color.Gray {
switch src := img.(type) {
case *EncodedImage:
pixels, err := src.Image()
if err != nil {
return color.Gray{}
}
return GrayAt(pixels, x, y)
case *colorImage:
return GrayAt(src.source, x, y)
case *maskedTIFFImage:
if src.NRGBA64At(x, y).A == 0 {
return color.Gray{}
}
bounds, size := src.source.Bounds(), src.bounds.Size()
sx := bounds.Min.X + int((uint64(x-src.bounds.Min.X)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X))
sy := bounds.Min.Y + int((uint64(y-src.bounds.Min.Y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y))
return GrayAt(src.source, sx, sy)
case *transparentEdgeImage:
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
return color.Gray{}
}
bounds := src.source.Bounds()
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
case *transparentEdgeImage64:
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
return color.Gray{}
}
bounds := src.source.Bounds()
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
case *orientedTIFFImage:
point, ok := src.sourcePoint(x, y)
if !ok {
return color.Gray{}
}
return GrayAt(src.source, point.X, point.Y)
case *planarTIFFImage:
if src.cmyk {
return src.cmykGrayAt(x, y)
}
}
if src, ok := img.(*image.Gray); ok {
return src.GrayAt(x, y)
}
@@ -662,12 +702,19 @@ func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int
return img, 0, nil
}
if src, ok := img.(*EncodedImage); ok && src.MIME() == "image/jpeg" {
if src.profile != nil || src.config.ColorModel == color.CMYKModel {
pixels, err := src.ImageContext(ctx)
return pixels, 0, err
}
return img, 0, nil
}
source, err := PixelDataContext(ctx, img)
if err != nil {
return nil, 0, err
}
if encoded, ok := img.(*EncodedImage); ok && (encoded.profile != nil || encoded.pngColor != nil) {
img = source
}
switch source.(type) {
case *image.Gray, *image.Gray16, *image.YCbCr, *image.CMYK:
return img, 0, nil
+26 -1
View File
@@ -14,7 +14,10 @@
package image
import "image"
import (
"image"
"image/color"
)
// StorageCost 预留十六位像素空间,并计入编码、调色板与底层像素缓冲容量
// 包装图像可实现BackingImage() image.Image,使底层共享缓冲计入预算
@@ -28,6 +31,25 @@ func StorageCost(img image.Image, limit int) int {
}
pixels := w * h * 8
if encoded, ok := img.(*EncodedImage); ok {
if encoded.pngColor != nil {
if limit-cost < 512 {
return limit + 1
}
cost += 512
}
if cap(encoded.profile) > limit-cost {
return limit + 1
}
cost += cap(encoded.profile)
if encoded.profile != nil || encoded.pngColor != nil || encoded.config.ColorModel == color.CMYKModel {
if w > (limit-cost)/12/h {
return limit + 1
}
pixels = w * h * 12
}
if result := encoded.result.Load(); result != nil && result.image != nil {
pixels = max(pixels, StorageCost(result.image, limit-cost))
}
if cap(encoded.data) > limit-cost-pixels {
return limit + 1
}
@@ -35,6 +57,9 @@ func StorageCost(img image.Image, limit int) int {
}
var buffers [5]int
switch source := img.(type) {
case *colorImage:
buffers[0] = cap(source.Pix)
buffers[1] = StorageCost(source.source, limit-cost)
case *image.RGBA:
buffers[0] = cap(source.Pix)
case *image.RGBA64:
+18 -8
View File
@@ -23,6 +23,8 @@ import (
"image/color"
"io"
"math"
"github.com/xiaoqidun/pdfgo"
)
// tiffPlaneInput 将单分量目录附加到只读视图,像素仍从原始编码读取
@@ -60,14 +62,18 @@ func (layout TIFFLayout) needsSampleView() bool {
// 返回: uint64 每像素预算字节数
func (layout TIFFLayout) DecodedPixelBytes() uint64 {
samples := uint64(layout.Samples)
profile := uint64(0)
if layout.profileSize != 0 {
profile = 8
}
if layout.Planar {
if layout.photometric == 5 && samples > 4 {
return 20
return 20 + profile
}
return 16
return 16 + profile
}
if !layout.needsSampleView() {
return 8
return 8 + profile
}
bytes := uint64(1)
if layout.depth == 2 || layout.depth == 4 {
@@ -75,7 +81,7 @@ func (layout TIFFLayout) DecodedPixelBytes() uint64 {
} else if layout.depth > 8 {
bytes = 2
}
return max(uint64(8), samples*bytes)
return max(uint64(8), samples*bytes) + profile
}
// decodeTIFFPixels 按存储方式解码指定目录并统一颜色和显示方向
@@ -647,12 +653,12 @@ func (p *planarTIFFImage) ColorModel() color.Model {
return color.GrayModel
}
if p.alpha == 1 {
if p.depth > 8 {
if p.depth > 8 || p.cmyk {
return color.RGBA64Model
}
return color.RGBAModel
}
if p.depth > 8 {
if p.depth > 8 || p.cmyk {
return color.NRGBA64Model
}
return color.NRGBAModel
@@ -674,10 +680,14 @@ func (p *planarTIFFImage) components(x, y int) [4]uint16 {
return [4]uint16{}
}
}
black := maximum - min(uint32(ink[3]), maximum)
var components [4]float64
for i := range components {
components[i] = float64(min(uint32(ink[i]), maximum)) / float64(maximum)
}
rgb, _ := (&pdfgo.ColorSpace{Model: "DeviceCMYK"}).SRGB(components[:], "")
var values [4]uint16
for i := range 3 {
values[i] = uint16((maximum - min(uint32(ink[i]), maximum)) * black / maximum)
values[i] = uint16(math.Round(rgb[i] * float64(maximum)))
}
values[3] = ink[4]
return values
+19 -3
View File
@@ -31,7 +31,7 @@ import (
func parseTIFFColorProfile(kind string, data []byte) (*pdfgo.ColorSpace, error) {
model := map[string]string{"GRAY": "GRAY", "RGB": "RGB ", "CMYK": "CMYK"}[strings.ToUpper(kind)]
if len(data) < 132 || model == "" || string(data[16:20]) != model {
return nil, fmt.Errorf("ICC source model does not match OFD color space %q", kind)
return nil, fmt.Errorf("ICC source model does not match image color space %q", kind)
}
return pdfgo.NewICCColorSpace(data)
}
@@ -75,6 +75,13 @@ func readTIFFColorProfile(ctx context.Context, data []byte, layout TIFFLayout) (
// 入参: ctx 取消上下文,可为nil, source 解码图像, profile 已校验的颜色配置
// 返回: image.Image 独立透明度图像, error 颜色变换或取消错误
func applyTIFFColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
return applyImageColorProfile(ctx, source, profile, false)
}
// applyImageColorProfile 按源配置转换显示像素,缺省传递函数在浮点域解码
// 入参: ctx 取消上下文, source 源像素, profile 颜色空间, linearSRGB 是否先解码sRGB传递函数
// 返回: image.Image 十六位显示图像, error 变换或取消错误
func applyImageColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace, linearSRGB bool) (image.Image, error) {
if ctx == nil {
ctx = context.Background()
}
@@ -144,7 +151,16 @@ func applyTIFFColorProfile(ctx context.Context, source image.Image, profile *pdf
alpha = pixel.A
values = [4]float64{float64(pixel.R) / 65535, float64(pixel.G) / 65535, float64(pixel.B) / 65535}
}
rgb, err := profile.RGB(values[:profile.Components()], profile.DefaultIntent())
if linearSRGB {
for i := range 3 {
if values[i] <= 0.04045 {
values[i] /= 12.92
} else {
values[i] = math.Pow((values[i]+0.055)/1.055, 2.4)
}
}
}
rgb, err := profile.SRGB(values[:profile.Components()], profile.DefaultIntent())
if err != nil {
return nil, err
}
@@ -161,7 +177,7 @@ func applyTIFFColorProfile(ctx context.Context, source image.Image, profile *pdf
if err := ctx.Err(); err != nil {
return nil, err
}
return result, nil
return &colorImage{NRGBA64: result, source: source}, nil
}
// newTIFFProfileTable 为离散颜色分配查找表,预乘颜色及小图保持逐像素变换
+1 -5
View File
@@ -76,11 +76,7 @@ func (c *TransferCache) Resolve(reader *pdfgo.Reader, style pdfgo.Style, model p
// 入参: space 合成空间, values 非预乘分量
// 返回: [3]float64 显示颜色, error 校准颜色转换错误
func OutputColor(space *pdfgo.ColorSpace, values [4]float64) ([3]float64, error) {
if space.Model == "DeviceCMYK" && !space.Calibrated() {
black := 1 - values[3]
return [3]float64{(1 - values[0]) * black, (1 - values[1]) * black, (1 - values[2]) * black}, nil
}
return space.RGB(values[:space.Components()], "RelativeColorimetric")
return space.SRGB(values[:space.Components()], "RelativeColorimetric")
}
// NormalBlend 判断混合模式是否为普通覆盖
+120
View File
@@ -15,6 +15,11 @@
package pdf
import (
"bytes"
"compress/zlib"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"math"
@@ -23,6 +28,121 @@ import (
"github.com/xiaoqidun/pdfgo"
)
// EncodeCMYKTIFF 将直接四色图像保存为十六位无损TIFF,不经过显示颜色转换
// 入参: ctx 取消上下文, source PDF源图像
// 返回: []byte 含ICC和非预乘透明度的TIFF,非四色图像为nil, error 解码或编码错误
func EncodeCMYKTIFF(ctx context.Context, source *pdfgo.Image) ([]byte, error) {
if ctx == nil || source == nil {
return nil, fmt.Errorf("missing CMYK image context or source")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if source.ImageMask {
return nil, nil
}
family, err := source.ColorSpaceFamily()
if err != nil || family != "DeviceCMYK" && family != "ICCBased" {
return nil, err
}
if family == "ICCBased" {
count, err := source.ColorSpaceComponents()
if err != nil || count != 4 {
return nil, err
}
}
components, err := source.DecodeComponentsViewContext(ctx)
if err != nil {
return nil, err
}
if components.Space == nil || components.Space.Model != "DeviceCMYK" || components.Space.Components() != 4 || components.ColorantSpace != nil {
return nil, nil
}
bounds := components.Rect
width, height := bounds.Dx(), bounds.Dy()
if width <= 0 || height <= 0 || width > int(^uint(0)>>1)/10 || uint64(width) > uint64(^uint32(0))/10 || uint64(height) > uint64(^uint32(0)) {
return nil, fmt.Errorf("invalid CMYK TIFF dimensions")
}
profile := components.Space.ICCProfile(source.Intent)
count := 12
if len(profile) != 0 {
count++
}
bitsOffset := uint64(8 + 2 + count*12 + 4)
profileOffset := bitsOffset + 10
stripOffset := (profileOffset + uint64(len(profile)) + 1) &^ uint64(1)
if stripOffset > uint64(^uint32(0)) {
return nil, fmt.Errorf("CMYK TIFF profile exceeds size limit")
}
var compressed bytes.Buffer
writer := zlib.NewWriter(&compressed)
row := make([]byte, width*10)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)%1024 == 0 {
if err := ctx.Err(); err != nil {
writer.Close()
return nil, err
}
}
values, alpha := components.ValuesAt(x, y)
pixel := row[(x-bounds.Min.X)*10:]
for channel, value := range [5]float64{values[0], values[1], values[2], values[3], alpha} {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 1 {
writer.Close()
return nil, fmt.Errorf("invalid CMYK TIFF component")
}
binary.LittleEndian.PutUint16(pixel[channel*2:], uint16(math.Round(value*65535)))
}
}
if _, err := writer.Write(row); err != nil {
writer.Close()
return nil, err
}
if stripOffset+uint64(compressed.Len()) > uint64(^uint32(0)) {
writer.Close()
return nil, fmt.Errorf("CMYK TIFF exceeds size limit")
}
}
if err := writer.Close(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
total := stripOffset + uint64(compressed.Len())
if total > uint64(^uint32(0)) || total > uint64(int(^uint(0)>>1)) {
return nil, fmt.Errorf("CMYK TIFF exceeds size limit")
}
data := make([]byte, int(total))
copy(data, "II")
binary.LittleEndian.PutUint16(data[2:], 42)
binary.LittleEndian.PutUint32(data[4:], 8)
binary.LittleEndian.PutUint16(data[8:], uint16(count))
entries := [][4]uint32{
{256, 4, 1, uint32(width)}, {257, 4, 1, uint32(height)},
{258, 3, 5, uint32(bitsOffset)}, {259, 3, 1, 8}, {262, 3, 1, 5},
{273, 4, 1, uint32(stripOffset)}, {277, 3, 1, 5}, {278, 4, 1, uint32(height)},
{279, 4, 1, uint32(compressed.Len())}, {284, 3, 1, 1}, {332, 3, 1, 1}, {338, 3, 1, 2},
}
if len(profile) != 0 {
entries = append(entries, [4]uint32{34675, 7, uint32(len(profile)), uint32(profileOffset)})
}
for i, entry := range entries {
p := data[10+i*12:]
binary.LittleEndian.PutUint16(p, uint16(entry[0]))
binary.LittleEndian.PutUint16(p[2:], uint16(entry[1]))
binary.LittleEndian.PutUint32(p[4:], entry[2])
binary.LittleEndian.PutUint32(p[8:], entry[3])
}
for i := range 5 {
binary.LittleEndian.PutUint16(data[int(bitsOffset)+2*i:], 16)
}
copy(data[int(profileOffset):], profile)
copy(data[int(stripOffset):], compressed.Bytes())
return data, nil
}
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
type StencilImage struct {
Source image.Image