一種使用iText7渲染引擎去除文字水印方法的過程記錄

charset發表於2024-09-29

有一種PDF文字,使用旋轉過的字型來作為水印。檔案經過密碼保護,不能透過編輯的方法去除。
轉載請保留這一段文字:charset#cnblogs,謝絕CSDN知乎之流轉載

注意:擁有水印並且編輯密碼包含的PDF文件可能具有版權保護,本文僅從技術角度討論可能性。

正常檔案可以被開啟而且顯示無誤,使用iText7的渲染引擎來獲取渲染專案,透過對目標文字的隱藏來達到去除文字水印的目的。

以下列舉了一些使用過程中的注意點和坑:

  • 環境:Windows 11 Home Edition 23H2
  • 機器:Lenovo L490 i5-8265U@1.6GHz 8C16G
  • 軟體:.NET 8.0.400, RoslynPad 19.1
  1. 引用itext7, 8.0.5, itext7.bouncy-castle-adapter, 8.0.5, itext7.font-asian, 8.0.5,中間用來解析加密過的PDF,最後解析亞洲文字。

  2. 寫一個TextExtractionStrategy繼承IEventListener

class TextExtractionStrategy : IEventListener {
    readonly List<ObjectRenderInfo> info;
    public TextExtractionStrategy(List<ObjectRenderInfo> info) => this.info = info;
    public void EventOccurred(IEventData data, EventType type) {
        switch (data) {
            case TextRenderInfo renderInfo:
                info.Add(new ObjectRenderInfo {
                    Text = renderInfo.GetText(),
                    Matrix = renderInfo.GetTextMatrix(),
                    FontName = renderInfo.GetFont(),
                    FontSize = renderInfo.GetFontSize(),
                    Color = renderInfo.GetFillColor(),
                    Width = renderInfo.GetUnscaledWidth()
                });
                break;
            case ImageRenderInfo imageRender:
                var image = imageRender.GetImage();
                info.Add(new ObjectRenderInfo { Image = image.GetImageBytes(), Vector = imageRender.GetStartPoint(), Height = image.GetHeight(), Width = image.GetWidth(), Matrix = imageRender.GetImageCtm() });
                break;
            case PathRenderInfo pathRender:
                var operation = pathRender.GetOperation();
                if (operation != PathRenderInfo.NO_OP) {
                    info.Add(new ObjectRenderInfo {
                        Path = pathRender.GetPath(),
                        Matrix = pathRender.GetCtm(),
                        Width = pathRender.GetGraphicsState().GetLineWidth(),
                        Color = pathRender.GetStrokeColor(),
                        Operation = pathRender.GetOperation(),
                    });
                }
                break;
        }
    }
    public ICollection<EventType> GetSupportedEvents() => new List<EventType> { EventType.RENDER_TEXT, EventType.RENDER_IMAGE, EventType.RENDER_PATH };
}

沒啥好說的,註冊三種渲染事件,並且在事件回撥的時候透過info將傳遞的內容記錄下來。

List<ObjectRenderInfo> info = new(256);
var strategy = new TextExtractionStrategy(info);
var processor = new PdfCanvasProcessor(strategy);
  1. 字型的處理

因為PDF的字型直接使用的路不通,所以使用簡單粗暴的對映本地字型檔案的方式進行。如果有一些複式字型不考慮。

PdfFont GetFont(string name) {
    var fontName = "SimSun.ttc,0";
    if (name.Contains("SimHei", StringComparison.CurrentCultureIgnoreCase)) fontName = "SimHei.ttf";
    else if (name.Contains("Times", StringComparison.CurrentCultureIgnoreCase)) fontName = "times.ttf";
    else if (name.Contains("FangSong", StringComparison.CurrentCultureIgnoreCase)) fontName = "simfang.ttf";
    else if (name.Contains("DengXian", StringComparison.CurrentCultureIgnoreCase)) fontName = "deng.ttf";
    else if (name.Contains("Arial", StringComparison.CurrentCultureIgnoreCase)) fontName = "arial.ttf";
    else if (name.Contains("Verdana", StringComparison.CurrentCultureIgnoreCase)) fontName = "Verdana.ttf";
    else if (name.Contains("KaiTi", StringComparison.CurrentCultureIgnoreCase)) fontName = "simkai.ttf";
    else if (name.Contains("Cambria", StringComparison.CurrentCultureIgnoreCase)) fontName = "Cambria.ttc,0";
    else if (name.Contains("YuGothic", StringComparison.CurrentCultureIgnoreCase)) fontName = "YuGothL.ttc,0";
    else if (name.Contains("Calibri", StringComparison.CurrentCultureIgnoreCase)) fontName = "Calibri.ttf";
    else if (name.Contains("CourierNew", StringComparison.CurrentCultureIgnoreCase)) fontName = "cour.ttf";
    else if (name.Contains("Consolas", StringComparison.CurrentCultureIgnoreCase)) fontName = "consola.ttf";
    if (!fonts.TryGetValue(fontName, out var font)) {
        font = PdfFontFactory.CreateFont($@"C:\Windows\Fonts\{fontName}", PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED);
        fonts.Add(fontName, font);
    }
    //Console.Write($"{name} -> {fontName}");
    return font;
}

注意:每個PDF需要建立自己的字型例項,不然儲存的時候會有異常,引用的資源屬於別的檔案。

  1. 渲染過程
    只有下列三種渲染的方式。
for (int i = 1; i < docSource.GetNumberOfPages(); i++) {
    info.Clear();
    var page = docSource.GetPage(i);
    //處理原始檔案的每一頁
    processor.ProcessPageContent(page);
    //根據List<ObjectRenderInfo>內容進行重新繪製
    foreach (var objRenderInfo in info) {
    }
}

4.1 渲染文字

如果需要擦除的水印文字就在這裡就很方便的透過判斷即可。

var font = GetFont(objRenderInfo.Font.GetFontProgram().GetFontNames().GetFontName());
var paragraph = new Paragraph(objRenderInfo.Text).SetFixedPosition(i, x, y, objRenderInfo.Width * 2)
    .SetFont(font).SetFontSize(fontSize).SetFontColor(objRenderInfo.Color);
docTarget.Add(paragraph);

本過程的靈魂所在就是SetFixedPosition(int pageNumber, float left, float bottom, float width)方法,比對圖形處理來說會簡單一些,直接對pageNumber指定的頁進行繪製文字操作即可。注意width所指的引數這裡使用了objRenderInfo.Width * 2,試驗過僅用Width可能會導致文字折行,簡單起見給定了一個經驗值。

4.2 渲染圖形

繪製圖形會比較多的坑。需要注意的幾個點如下:

  • PdfPage的獲取:Path的繪製需要PdfCanvas,而後者需要從PdfPage建立,顯而易見的想從docTarge.GetPage(i)獲取頁面例項,可惜想得太天真了。
PdfPage? page = null;
try { page = docTarget.GetPage(i); } catch (Exception) { page = docTarget.AddNewPage(); }
  • Matrix轉換矩陣的使用:如果簡單的使用PathRenderInfo的幾個引數進來不足以繪製和原先一樣的圖形,是因為有偏移和縮放。
var offset = new Point(objRenderInfo.Matrix.Get(6), objRenderInfo.Matrix.Get(7));
float scaleX = objRenderInfo.Matrix.Get(0), scaleY = objRenderInfo.Matrix.Get(4);
(globalOffset, globalScaleX, globalScaleY) = (offset, scaleX, scaleY);
  • globalOffset, globalScaleX, globalScaleY:單獨需要將這幾個值儲存下來作為本頁的全域性偏移量以及縮放量,是遇到了一些例如流程圖、表格,使用Path繪製的時候PathRenderInfo記載進了Matrix變數。在繪製Shape和上文Text的時候,需要進行計算。
//繪製Text
var x = objRenderInfo.Matrix.Get(6) * globalScaleX + globalOffset.x;
var y = objRenderInfo.Matrix.Get(7) * globalScaleY + globalOffset.y;
var fontSize = (float)(objRenderInfo.FontSize * Math.Sqrt(globalScaleX * globalScaleY));

在這裡fontSize做了特殊處理,短時間內還沒法知道到底是X還是Y軸需要縮放。

//繪製圖形
foreach (var sub in objRenderInfo.Path.GetSubpaths()) {
    canvas.SaveState();
    foreach (var shape in sub.GetSegments()) {
        switch(shape) {
            case iText.Kernel.Geom.Line line:
            //處理直線
            break;
            case iText.Kernel.Geom.BezierCurve curve:
            //處理曲線
            break;
            default: Console.Write(shape); break;
        }
    }
    if (sub.IsClosed()) canvas.ClosePath();
    canvas.Stroke();
    canvas.RestoreState();
}
  • 繪製直線:
var points = line.GetBasePoints();
canvas.MoveTo(offset.x + points[0].x * scaleX, offset.y + points[0].y * scaleY);
canvas.LineTo(offset.x + points[1].x * scaleX, offset.y + points[1].y * scaleY);
  • 繪製曲線:我遇到的這個檔案裡面是3個點確定一個曲線,理論上按照文件也會有2個點。以下省略的點個數判斷。
var points = curve.GetBasePoints();
canvas.MoveTo(offset.x + points[0].x * scaleX, offset.y + points[0].y * scaleY);
canvas.CurveTo(offsetx + points[1].x * scaleX, offset.y + points[1].y * scaleY,
    offsetx + points[2].x * scaleX, offset.y + points[2].y * scaleY,
    offsetx + points[3].x * scaleX, offset.y + points[3].y * scaleY);

應該還存在更簡單的使用Matrix的API可以縮減程式碼量,不過時間太少沒有深入研究

4.3 渲染影像
影像的繪製相對簡單,但是還有一些坑沒填上。比如獲取的ImageBytes展示出來是黑塊,在不影響閱讀的情況下還沒研究修復。由於直接可以繪製在指定頁面,所以篇幅會很小。

var image = new Image(ImageDataFactory.Create(objRenderInfo.Image))
    .SetFixedPosition(i, objRenderInfo.Vector.Get(0), objRenderInfo.Vector.Get(1));
if (objRenderInfo.Width > page.GetPageSize().GetWidth())
    image.SetAutoScale(true);
else
    image.SetWidth(objRenderInfo.Width).SetHeight(objRenderInfo.Height);
docTarget.Add(image);
  1. 後話
    ObjectRenderInfo的定義
class ObjectRenderInfo {
    public string? Text { get; set; }
    public PdfFont? FontName { get; set; }
    public float FontSize { get; set; }
    public float Width { get; set; }
    public float Height { get; set; }
    public Color? Color { get; set; }
    public Color? Background { get; set; }
    public Matrix? Matrix { get; set; }
    public byte[]? Image { get; set; }
    public Vector? Vector { get; set; }
    public iText.Kernel.Geom.Path? Path { get; set; }
    public int Operation { get; set; }
}

使用上述程式碼的話,幾乎可以將原先PDF內容繪製到新的檔案,不過還存在兩個問題。

  • 一些圖形中帶文字的位置會亂。目前尚未找到解決方法。
  • 一些影像展示不出來,僅是一個黑塊,因為沒有分析二進位制影像記憶體所以還未找到解決方法。

相關文章