Ask me anything
Ithy Logo
Sign in to save

解锁PDF精准裁剪:Spring Boot API实战指南

通过Base64和坐标,轻松实现Java后端PDF文档区域裁剪。

java-springboot-pdf-crop-api-chinese-ybqsqhhx

您好!本指南将详细介绍如何使用Java Spring Boot框架开发一个API接口。该接口能够接收Base64编码的PDF文件以及一组精确的坐标点(例如,代表矩形区域的左上、右上、右下、左下角),并根据这些坐标对PDF文件进行裁剪。我们将重点使用Apache PDFBox库,并探讨相关的核心概念和最佳实践。

核心亮点

  • 端到端实现: 从接口设计、依赖配置到使用Apache PDFBox(或iText)实现核心裁剪逻辑的完整Spring Boot解决方案。
  • 坐标系详解: 深入解析PDF的坐标系统,以及如何转换输入坐标以确保裁剪操作的精确性。
  • 代码与实践: 提供包含注释的、可操作的Java代码示例,并涵盖错误处理、性能优化和安全注意事项等最佳实践。

项目设置与依赖管理

首先,确保您的开发环境中已安装Java Development Kit (JDK) 和Maven或Gradle构建工具。然后,创建一个新的Spring Boot项目或在现有项目基础上进行修改。

创建Spring Boot项目

您可以使用Spring Initializr (https://start.spring.io/) 或您偏好的IDE(如IntelliJ IDEA, Eclipse)来快速生成一个基础的Spring Boot项目。请确保包含 "Spring Web" 依赖。

添加Maven/Gradle依赖

为了处理PDF文件和Base64编码,您需要在项目的构建配置文件(Maven的 `pom.xml` 或 Gradle的 `build.gradle`)中添加必要的库依赖。

Apache PDFBox依赖配置 (推荐)

PDFBox是一个功能强大的开源Java库,用于处理PDF文档。它由Apache软件基金会维护,采用Apache License 2.0许可证。

对于Maven项目,在 `` 部分添加:

<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.2</version> <!-- 建议检查并使用最新的稳定版本 -->
</dependency>

<!-- Spring Boot Web Starter (通常已包含) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Apache Commons Codec (可选, Java 8+ 内置Base64) -->
<!--
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.16.1</version>
</dependency>
-->

对于Gradle项目,在 `dependencies` 代码块中添加:

implementation 'org.apache.pdfbox:pdfbox:3.0.2' // 建议检查并使用最新的稳定版本
implementation 'org.springframework.boot:spring-boot-starter-web'
// implementation 'commons-codec:commons-codec:1.16.1' // 可选

iText依赖配置 (备选方案)

iText是另一个流行的PDF处理库,功能同样强大。请注意,iText 7及更高版本采用AGPL许可证,商业使用可能需要购买许可证。

Maven依赖:

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext7-core</artifactId>
    <version>7.2.5</version> <!-- 示例版本,请检查最新版 -->
    <type>pom</type>
</dependency>

Gradle依赖:

implementation 'com.itextpdf:itext7-core:7.2.5' // 示例版本,请检查最新版

构建API接口

我们将创建一个RESTful API端点,它接收HTTP POST请求,请求体中包含Base64编码的PDF和坐标数据。

定义REST Controller

创建一个Java类,使用 `@RestController` 注解标记,并定义处理请求的方法。

设计请求体DTO (Data Transfer Object)

创建一个简单的Java类 (POJO) 来映射传入的JSON请求体。这有助于类型安全和代码清晰度。

package com.example.pdfcropper.dto;

import java.util.List;

public class PdfCropRequest {
    private String pdfBase64;
    // 预期格式: ["x1,y1", "x2,y2", "x3,y3", "x4,y4"] 或类似的数字列表
    private List<Float> coordinates; 

    // Getters 和 Setters
    public String getPdfBase64() {
        return pdfBase64;
    }

    public void setPdfBase64(String pdfBase64) {
        this.pdfBase64 = pdfBase64;
    }

    public List<Float> getCoordinates() {
        return coordinates;
    }

    public void setCoordinates(List<Float> coordinates) {
        this.coordinates = coordinates;
    }
}

注意: 坐标的格式可以根据您的具体需求调整。示例中使用了 `List`,假设传入的是一个扁平化的坐标列表 `[x1, y1, x2, y2, x3, y3, x4, y4]`。如果传入的是如 `"x,y"` 格式的字符串列表,DTO和后续解析逻辑需要相应调整。

编写 `@PostMapping` 端点

在Controller类中,添加一个方法来处理POST请求,例如映射到 `/api/pdf/crop` 路径。

package com.example.pdfcropper.controller;

import com.example.pdfcropper.dto.PdfCropRequest;
import com.example.pdfcropper.service.PdfCropService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/pdf")
public class PdfCropController {

    @Autowired
    private PdfCropService pdfCropService;

    @PostMapping("/crop")
    public ResponseEntity<String> cropPdf(@RequestBody PdfCropRequest request) {
        try {
            if (request.getPdfBase64() == null || request.getPdfBase64().isEmpty()) {
                return ResponseEntity.badRequest().body("Missing PDF Base64 data.");
            }
            if (request.getCoordinates() == null || request.getCoordinates().size() != 8) {
                 // 假设需要8个值代表4个点的x,y坐标
                return ResponseEntity.badRequest().body("Invalid coordinates format. Expected 8 float values.");
            }

            byte[] croppedPdfBytes = pdfCropService.cropPdf(request.getPdfBase64(), request.getCoordinates());
            
            // 将裁剪后的PDF也以Base64形式返回
            String croppedBase64 = java.util.Base64.getEncoder().encodeToString(croppedPdfBytes);

            // 或者返回文件流
            /*
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_PDF);
            headers.setContentDispositionFormData("attachment", "cropped.pdf");
            return new ResponseEntity<>(croppedPdfBytes, headers, HttpStatus.OK);
            */

            return ResponseEntity.ok(croppedBase64);

        } catch (IllegalArgumentException e) {
            // 处理特定预期错误,如无效Base64或坐标
             return ResponseEntity.badRequest().body("Error processing request: " + e.getMessage());
        } catch (Exception e) {
            // 处理一般性PDF处理错误或其他意外异常
            // 考虑添加日志记录 e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error processing PDF: " + e.getMessage());
        }
    }
}

核心处理逻辑

这部分是实现PDF裁剪功能的核心,我们将把具体逻辑封装在一个Service类中 (`PdfCropService`)。

Base64解码与PDF加载

首先,需要将传入的Base64编码字符串解码为字节数组,然后使用PDFBox加载这个字节数组来创建一个 `PDDocument` 对象。

解码与加载步骤

  1. 从 `PdfCropRequest` 对象获取Base64字符串。
  2. 使用 `java.util.Base64.getDecoder().decode()` 方法进行解码,得到 `byte[]`。
  3. 将字节数组包装在 `ByteArrayInputStream` 中。
  4. 调用 `PDDocument.load(inputStream)` 加载PDF文档。

理解PDF坐标系与坐标转换

这是实现精确裁剪的关键步骤。PDF内部的坐标系统通常以页面的左下角为原点 (0, 0),X轴向右延伸,Y轴向上延伸。单位是点(points),1英寸等于72点。

图示:PDF页面坐标系,原点在左下角

图示:PDF页面坐标系,原点位于左下角。

输入坐标解析

您提供的坐标格式为 `[左上x, 左上y, 右上x, 右上y, 右下x, 右下y, 左下x, 左下y]`。例如 `[3.0, 17.0, 869.0, 17.0, 869.0, 387.0, 3.0, 387.0]`。这些坐标可能来自用户界面或其他系统,其原点可能在左上角。因此,需要将其转换为PDF的坐标系统。

  • X坐标: 通常可以直接使用,因为左右方向一致。我们需要找到最小X (`minX`) 和最大X (`maxX`) 来确定裁剪框的左右边界。在示例中,`minX = 3.0`, `maxX = 869.0`。
  • Y坐标: 这是需要转换的部分。PDF的Y轴向上,而常见的屏幕或图像坐标Y轴向下。我们需要知道页面的高度 (`pageHeight`) 来进行转换。同时,要找到输入坐标中的最小Y (`inputMinY`) 和最大Y (`inputMaxY`)。在示例中,`inputMinY = 17.0`, `inputMaxY = 387.0`。

计算裁剪框 (PDRectangle)

PDFBox 使用 `PDRectangle` 对象来定义一个矩形区域。它需要四个参数:左下角的X坐标 (`llx`)、左下角的Y坐标 (`lly`)、矩形的宽度 (`width`) 和矩形的高度 (`height`)。

转换逻辑如下:

  • `llx = minX`
  • `lly = pageHeight - inputMaxY` (将输入的最大Y(视觉上的底部)转换为PDF的底部Y坐标)
  • `width = maxX - minX`
  • `height = inputMaxY - inputMinY` (高度是输入Y坐标的最大值减最小值)

因此,裁剪框定义为: `new PDRectangle(minX, pageHeight - inputMaxY, maxX - minX, inputMaxY - inputMinY)`。

示意图:坐标网格纸,有助于理解坐标系统转换

示意图:坐标网格,有助于可视化坐标转换。

使用PDFBox实现PDF裁剪

获取到需要裁剪的页面 (`PDPage`) 对象和计算好的 `PDRectangle` 后,就可以进行裁剪操作了。

理解 `MediaBox` 与 `CropBox`

PDF页面有多个“盒子”定义其边界:

  • MediaBox: 定义了页面的物理介质边界,是最大的盒子。
  • CropBox: 定义了页面内容可见或将被显示的区域。默认情况下,它等于MediaBox。裁剪操作通常是修改CropBox。
  • 其他盒子 (BleedBox, TrimBox, ArtBox) 用于印刷和特定内容区域。

为了确保裁剪效果在各种PDF阅读器中都能正确显示,通常建议同时设置 `CropBox` 和 `MediaBox` 为新的裁剪矩形。

设置裁剪框与保存

  1. 从 `PDDocument` 获取目标页面,通常是第一页:`PDPage page = document.getPage(0);` (注意页码从0开始)。
  2. 获取页面原始的 `MediaBox` 以计算高度:`float pageHeight = page.getMediaBox().getHeight();`
  3. 根据上述逻辑计算 `PDRectangle cropRectangle`。
  4. 设置页面的 `CropBox`: `page.setCropBox(cropRectangle);`
  5. (重要) 同时设置 `MediaBox`: `page.setMediaBox(cropRectangle);` 这可以避免某些阅读器忽略CropBox而显示原始内容。
  6. 创建一个 `ByteArrayOutputStream` 来保存修改后的PDF。
  7. 调用 `document.save(outputStream);`。
  8. 关闭文档 `document.close();` 以释放资源。
  9. 从 `ByteArrayOutputStream` 获取裁剪后的PDF字节数组 `outputStream.toByteArray();`。

备选方案:使用iText裁剪

如果选择使用iText,过程类似,但API调用略有不同。

  1. 加载PDF: `PdfDocument pdfDoc = new PdfDocument(new PdfReader(inputStream), new PdfWriter(outputStream));`
  2. 获取页面: `PdfPage page = pdfDoc.getPage(1);` (iText页码从1开始)
  3. 计算坐标和 `Rectangle`: `com.itextpdf.kernel.geom.Rectangle cropRect = new com.itextpdf.kernel.geom.Rectangle(llx, lly, width, height);` (注意坐标系转换同样适用)
  4. 设置CropBox: `page.setCropBox(cropRect);`
  5. (可选) 设置MediaBox: `page.setMediaBox(cropRect);`
  6. 关闭文档: `pdfDoc.close();`

完整代码示例 (Spring Boot + PDFBox Service)

下面是一个 `PdfCropService` 的示例实现,整合了上述逻辑。

package com.example.pdfcropper.service;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.Collections;
import java.util.List;

@Service
public class PdfCropService {

    public byte[] cropPdf(String base64Pdf, List<Float> coordinates) throws IOException {
        byte[] decodedPdf;
        try {
            decodedPdf = Base64.getDecoder().decode(base64Pdf);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("Invalid Base64 PDF data.", e);
        }

        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(decodedPdf);
             PDDocument document = PDDocument.load(inputStream)) {

            if (document.getNumberOfPages() == 0) {
                throw new IllegalArgumentException("PDF document is empty (0 pages).");
            }

            // --- 坐标解析与转换 ---
            // 假设 coordinates 是 [x1, y1, x2, y2, x3, y3, x4, y4]
            if (coordinates == null || coordinates.size() != 8) {
                 throw new IllegalArgumentException("Invalid coordinates: Expected 8 float values.");
            }
            
            // 提取所有X和Y坐标
            float x1 = coordinates.get(0); float y1 = coordinates.get(1);
            float x2 = coordinates.get(2); float y2 = coordinates.get(3);
            float x3 = coordinates.get(4); float y3 = coordinates.get(5);
            float x4 = coordinates.get(6); float y4 = coordinates.get(7);

            // 找到输入坐标的边界 (假设来自左上角为原点的系统)
            float minX = Collections.min(List.of(x1, x2, x3, x4));
            float maxX = Collections.max(List.of(x1, x2, x3, x4));
            float inputMinY = Collections.min(List.of(y1, y2, y3, y4)); // 输入坐标中的最小Y值 (视觉上的顶部)
            float inputMaxY = Collections.max(List.of(y1, y2, y3, y4)); // 输入坐标中的最大Y值 (视觉上的底部)

            // --- 裁剪逻辑 (以第一页为例) ---
            PDPage page = document.getPage(0); // 获取第一页
            PDRectangle mediaBox = page.getMediaBox();
            float pageHeight = mediaBox.getHeight();

            // 验证坐标是否在页面内 (可选但推荐)
            if (minX < 0 || maxX > mediaBox.getWidth() || inputMinY < 0 || inputMaxY > pageHeight) {
                 // 这里用 pageHeight 检查 inputMaxY 是因为输入的Y是从上到下的
                 // throw new IllegalArgumentException("Coordinates are outside the page boundaries.");
                 // 或者进行裁剪修正,例如将超出部分截断
                 minX = Math.max(0, minX);
                 maxX = Math.min(mediaBox.getWidth(), maxX);
                 inputMinY = Math.max(0, inputMinY);
                 inputMaxY = Math.min(pageHeight, inputMaxY);
                 // 重新计算 width 和 height
            }


            // 计算PDF坐标系下的裁剪矩形 (PDRectangle)
            float pdfLlx = minX;
            float pdfLly = pageHeight - inputMaxY; // Y轴翻转
            float width = maxX - minX;
            float height = inputMaxY - inputMinY; // 高度是输入Y最大值减最小值

            if (width <= 0 || height <= 0) {
                 throw new IllegalArgumentException("Calculated crop area has zero or negative width/height.");
            }

            PDRectangle cropRectangle = new PDRectangle(pdfLlx, pdfLly, width, height);

            // 应用裁剪
            page.setCropBox(cropRectangle);
            page.setMediaBox(cropRectangle); // 同时设置MediaBox以提高兼容性

            // --- 保存结果 ---
            try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
                document.save(outputStream);
                return outputStream.toByteArray();
            }

        } catch (IOException e) {
            // Log error: e.printStackTrace();
            throw new IOException("Failed to load or process PDF document.", e);
        }
    }
}

流程可视化

PDF裁剪处理流程 Mindmap

下面的思维导图概述了从接收请求到返回裁剪后PDF的整个处理流程:

mindmap root["Spring Boot PDF裁剪API"] id1["接收请求 (POST /api/pdf/crop)"] id1_1["请求体 (JSON)"] id1_1_1["pdfBase64 (String)"] id1_1_2["coordinates (List)
[x1, y1, x2, y2, ...]"] id2["处理逻辑 (PdfCropService)"] id2_1["解码Base64 PDF"] id2_1_1["Base64.getDecoder().decode()"] id2_2["加载PDF文档"] id2_2_1["PDDocument.load(inputStream)
(使用PDFBox)"] id2_3["解析与转换坐标"] id2_3_1["提取 minX, maxX, inputMinY, inputMaxY"] id2_3_2["获取页面高度 (page.getMediaBox().getHeight())"] id2_3_3["计算PDF坐标 (Y轴翻转)
llx, lly, width, height"] id2_3_4["创建 PDRectangle"] id2_3_5["坐标验证 (边界检查)"] id2_4["执行裁剪"] id2_4_1["获取目标页面 (PDPage)"] id2_4_2["page.setCropBox(rectangle)"] id2_4_3["page.setMediaBox(rectangle)"] id2_5["保存裁剪后的PDF"] id2_5_1["document.save(outputStream)"] id2_6["错误处理"] id2_6_1["无效Base64"] id2_6_2["PDF加载失败"] id2_6_3["无效坐标"] id2_6_4["空PDF"] id3["返回响应"] id3_1["成功 (200 OK)"] id3_1_1["返回裁剪后PDF的Base64编码 (String)"] id3_1_2["(或) 返回PDF文件流"] id3_2["失败 (4xx/5xx)"] id3_2_1["返回错误信息 (String)"] id4["依赖库"] id4_1["Spring Boot Web"] id4_2["Apache PDFBox"] id4_3["(备选) iText"] id5["关键概念"] id5_1["PDF坐标系 (左下角原点)"] id5_2["MediaBox vs CropBox"] id5_3["Base64 编解码"]

PDF处理库对比 (PDFBox vs. iText)

选择PDFBox还是iText取决于您的具体需求和偏好。下面的雷达图从几个维度对这两个库进行了主观比较,尤其侧重于裁剪这类常见任务。

关键特性雷达图

此图表基于一般认知和社区反馈,比较了Apache PDFBox和iText在进行类似本任务的PDF操作时的相对表现。评分越高表示在该方面相对越优或越易用(评分范围3-10)。

解读: PDFBox在许可证(Apache 2.0更宽松)和针对简单任务(如本例中的矩形裁剪)的易用性方面可能略有优势。iText在功能全面性(支持更复杂的PDF操作)和商业支持方面表现突出,但其AGPL许可证对某些项目可能有限制。两者都有良好的社区支持和文档。


关键概念与术语

PDF Box模型概览

理解PDF页面定义的各种边界框对于精确控制内容区域至关重要。下表总结了主要的Box类型及其用途:

Box类型 描述 用途 与裁剪的关系
MediaBox 页面的物理介质边界 定义页面最大尺寸,打印或显示的基础 通常是最大的框,裁剪操作可能需要同步修改它以确保兼容性
CropBox 页面内容的可见或显示区域 定义PDF阅读器应显示的区域 主要修改目标,定义实际裁剪后的视图
BleedBox 出血区域边界 专业印刷,定义超出最终裁切线的区域 一般裁剪操作不直接涉及
TrimBox 最终裁切后的页面边界 专业印刷,指示页面将被裁切的位置 一般裁剪操作不直接涉及
ArtBox 页面有意义内容的边界 定义页面核心内容的区域,排除空白 一般裁剪操作不直接涉及,但可用于内容提取

在本例中,我们通过修改 `CropBox` 来实现裁剪,并建议同时更新 `MediaBox` 为相同尺寸。


最佳实践与注意事项

  • 错误处理: 实现健壮的错误处理机制,捕获并处理无效的Base64输入、损坏的PDF文件、无法加载PDF、无效或越界的坐标、计算出的裁剪区域无效(如零宽度/高度)等情况。返回清晰的错误信息给客户端。
  • 坐标验证: 在进行裁剪前,务必验证输入坐标是否有效,以及计算出的裁剪区域是否在页面边界内。可以考虑对超出边界的坐标进行截断处理或直接报错。
  • 多页PDF处理: 当前示例仅处理第一页。如果需要裁剪多页PDF,您需要迭代 `document.getPages()`,并为每一页应用相应的裁剪逻辑(可能需要为每页提供不同的坐标,或应用相同的裁剪到所有页面)。
  • 性能考量: 对于非常大的PDF文件,一次性将整个文件加载到内存 (`PDDocument.load()`) 可能导致内存溢出 (OutOfMemoryError)。可以研究PDFBox或iText提供的流式处理或分块处理机制(如果适用),但这会增加实现的复杂度。对于一般大小的PDF,当前方法通常足够。
  • 安全性: 对所有外部输入(Base64字符串、坐标)进行严格验证,防止潜在的安全风险,如注入攻击或导致意外行为的恶意输入。
  • 资源管理: 确保在使用完 `PDDocument` 等资源后,调用其 `close()` 方法释放文件句柄和内存。使用 try-with-resources 语句是管理这些资源的最佳方式。
  • 返回类型: 考虑API的调用者需要什么格式的输出。返回Base64字符串简单方便,但对于大文件可能效率不高。返回文件流 (`ResponseEntity`) 允许客户端直接下载文件,更适合大文件场景。

常见问题解答 (FAQ)

Q: 如何处理多页PDF,对每一页进行不同的裁剪?

A: 您需要修改API接口,使其能够接收每页对应的坐标。例如,`PdfCropRequest` 可以包含一个 `Map> pageCoordinates`,其中Key是页码(从0开始),Value是该页的坐标列表。然后在 `PdfCropService` 中,迭代文档的每一页 (`for (int i = 0; i < document.getNumberOfPages(); i++)`),获取对应页码的坐标,计算并应用 `CropBox` 和 `MediaBox`。

Q: 输入的坐标单位是什么?必须是点(points)吗?

A: PDF内部坐标和PDFBox/iText API通常使用点(points)作为单位(1 point = 1/72 inch)。如果您的输入坐标来自其他单位(如像素、毫米),您需要在应用裁剪之前进行转换。例如,从像素转换为点,需要知道屏幕或图像的DPI(每英寸点数):`points = pixels * 72 / DPI`。如果单位是毫米,转换公式为:`points = millimeters * 72 / 25.4`。

Q: Apache PDFBox 和 iText 哪个库更适合这个任务?

A: 对于本例中描述的基于矩形坐标的裁剪任务,两者都能很好地完成。PDFBox (Apache License 2.0) 在许可证方面更宽松,对于简单的裁剪操作API可能稍微直接一些。iText (AGPL/Commercial) 功能非常全面,文档和商业支持可能更完善,但需要注意许可证限制。您可以根据项目需求、团队熟悉度和许可证偏好来选择。参考上面的雷达图对比。

Q: 除了返回Base64编码,API可以返回裁剪后的PDF文件供下载吗?

A: 可以。在 `PdfCropController` 中,您可以将返回类型改为 `ResponseEntity`。获取到裁剪后的 `byte[] croppedPdfBytes` 后,设置正确的HTTP Headers,特别是 `Content-Type` 为 `application/pdf` 和 `Content-Disposition` 为 `attachment; filename="cropped.pdf"`。然后将字节数组作为响应体返回。示例代码片段已在Controller示例中注释说明。

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
// 设置文件名,让浏览器提示下载
headers.setContentDispositionFormData("attachment", "cropped_document.pdf"); 
return new ResponseEntity<>(croppedPdfBytes, headers, HttpStatus.OK);

参考资料

推荐探索

products.aspose.com
Crop PDF using Java - Aspose
api.itextpdf.com
iText Javadoc Home

Last updated April 15, 2025

AI-assisted research. Sources, methodology and corrections · Source-checked guides and tools · Explore published answers

Ask Ithy AI