使用 NextJS 和 ExpressJS 将语音转换为 PDF

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

随着语音界面变得越来越普遍,探索我们能够使用语音交互做些什么很有价值。例如,如果我们可以说些什么,然后将其转录并输出为可下载的 PDF 文件,会怎么样?

好吧,剧透警告:我们绝对可以做到!我们可以将一些库和框架拼凑在一起以实现它,这就是我们将在本文中一起做的事情。

我们正在使用的工具

首先,这两个是主要参与者:Next.js 和 Express.js。

Next.js 为 React 添加了额外的功能,包括构建静态网站的关键特性。由于它开箱即用提供的功能,例如动态路由、图像优化、内置域名和子域名路由、快速刷新、文件系统路由和 API 路由……以及许多其他功能,因此它是许多开发人员的首选。

在我们的案例中,我们绝对需要 Next.js 的API 路由 在我们的客户端服务器上。我们希望有一个路由可以获取文本文件,将其转换为 PDF,将其写入我们的文件系统,然后向客户端发送响应。

Express.js 允许我们使用路由、HTTP 助手和模板来启动一个小的 Node.js 应用程序。它是我们自己的 API 的服务器,当我们在事物之间传递和解析数据时,我们需要它。

我们还有一些其他依赖项将被使用

  1. react-speech-recognition:一个用于将语音转换为文本的库,使其可用于 React 组件。
  2. regenerator-runtime:一个用于解决在使用 react-speech-recognition 时 Next.js 中出现的“regeneratorRuntime 未定义”错误的库
  3. html-pdf-node:一个用于将 HTML 页面或公共 URL 转换为 PDF 的库
  4. axios:一个用于在浏览器和 Node.js 中发出 HTTP 请求的库
  5. cors:一个允许跨源资源共享的库

设置

我们要做的第一件事是创建两个项目文件夹,一个用于客户端,一个用于服务器。您可以随意命名它们。我分别将我的命名为audio-to-pdf-clientaudio-to-pdf-server

在客户端使用 Next.js 入门最快的方法是使用create-next-app 引导它。因此,打开您的终端,并从您的客户端项目文件夹中运行以下命令

npx create-next-app client

现在我们需要我们的 Express 服务器。我们可以通过 cd 进入服务器项目文件夹并运行 npm init 命令来获取它。一旦完成,将在服务器项目文件夹中创建一个 package.json 文件。

我们仍然需要实际安装 Express,所以现在让我们使用 npm install express 来完成。现在,我们可以在服务器项目文件夹中创建一个新的 index.js 文件,并将此代码放入其中

const express = require("express")
const app = express()

app.listen(4000, () => console.log("Server is running on port 4000"))

准备运行服务器了吗?

node index.js

为了继续前进,我们需要再创建几个文件夹和另一个文件

  • 在客户端项目文件夹中创建一个 components 文件夹。
  • components 子文件夹中创建一个 SpeechToText.jsx 文件。

在继续之前,我们需要进行一些清理工作。具体来说,我们需要用以下内容替换 pages/index.js 文件中的默认代码

import Head from "next/head";
import SpeechToText from "../components/SpeechToText";

export default function Home() {
  return (
    <div className="home">
      <Head>
        <title>Audio To PDF</title>
        <meta
          name="description"
          content="An app that converts audio to pdf in the browser"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <h1>Convert your speech to pdf</h1>

      <main>
        <SpeechToText />
      </main>
    </div>
  );
}

导入的 SpeechToText 组件最终将从 components/SpeechToText.jsx 导出。

让我们安装其他依赖项

好的,我们已经完成了应用程序的初始设置。现在,我们可以安装处理传递数据的库。

我们可以使用以下命令安装客户端依赖项:

npm install react-speech-recognition regenerator-runtime axios

接下来是我们的 Express 服务器依赖项,所以让我们 cd 进入服务器项目文件夹并安装它们

npm install html-pdf-node cors

现在可能是暂停并确保项目文件夹中的文件完好无损的好时机。这是您此时在客户端项目文件夹中应该具有的内容

/audio-to-pdf-web-client
├─ /components
|  └── SpeechToText.jsx
├─ /pages
|  ├─ _app.js
|  └── index.js
└── /styles
    ├─globals.css
    └── Home.module.css

以及您在服务器项目文件夹中应该具有的内容

/audio-to-pdf-server
└── index.js

构建 UI

好吧,如果无法与我们的语音到 PDF 进行交互,那么它就不会那么棒,所以让我们为它创建一个 React 组件,我们可以将其称为 <SpeechToText>

您可以完全使用自己的标记。这是我提供的一些想法,以便您了解我们正在组合的各个部分

import React from "react";

const SpeechToText = () => {
  return (
    <>
      <section>
        <div className="button-container">
          <button type="button" style={{ "--bgColor": "blue" }}>
            Start
          </button>
          <button type="button" style={{ "--bgColor": "orange" }}>
            Stop
          </button>
        </div>
        <div
          className="words"
          contentEditable
          suppressContentEditableWarning={true}
        ></div>
        <div className="button-container">
          <button type="button" style={{ "--bgColor": "red" }}>
            Reset
          </button>
          <button type="button" style={{ "--bgColor": "green" }}>
            Convert to pdf
          </button>
        </div>
      </section>
    </>
  );
};

export default SpeechToText;

此组件返回一个React 片段,其中包含一个 HTML <``section``> 元素,该元素包含三个 div

  • .button-container 包含两个按钮,用于启动和停止语音识别。
  • .words 具有 contentEditablesuppressContentEditableWarning 属性,以使此元素可编辑并抑制 React 的任何警告。
  • 另一个 .button-container 包含另外两个按钮,分别用于重置和将语音转换为 PDF。

样式是另一回事。我不会在这里详细介绍,但欢迎您使用我编写的一些样式作为您自己的 styles/global.css 文件的起点。

查看完整 CSS
html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}

a {
  color: inherit;
  text-decoration: none;
}

* {
  box-sizing: border-box;
}

.home {
  background-color: #333;
  min-height: 100%;
  padding: 0 1rem;
  padding-bottom: 3rem;
}

h1 {
  width: 100%;
  max-width: 400px;
  margin: auto;
  padding: 2rem 0;
  text-align: center;
  text-transform: capitalize;
  color: white;
  font-size: 1rem;
}

.button-container {
  text-align: center;
  display: flex;
  justify-content: center;
  gap: 3rem;
}

button {
  color: white;
  background-color: var(--bgColor);
  font-size: 1.2rem;
  padding: 0.5rem 1.5rem;
  border: none;
  border-radius: 20px;
  cursor: pointer;
}

button:hover {
  opacity: 0.9;
}

button:active {
  transform: scale(0.99);
}

.words {
  max-width: 700px;
  margin: 50px auto;
  height: 50vh;
  border-radius: 5px;
  padding: 1rem 2rem 1rem 5rem;
  background-image: -webkit-gradient(
    linear,
    0 0,
    0 100%,
    from(#d9eaf3),
    color-stop(4%, #fff)
  ) 0 4px;
  background-size: 100% 3rem;
  background-attachment: scroll;
  position: relative;
  line-height: 3rem;
  overflow-y: auto;
}

.success,
.error {
  background-color: #fff;
  margin: 1rem auto;
  padding: 0.5rem 1rem;
  border-radius: 5px;
  width: max-content;
  text-align: center;
  display: block;
}

.success {
  color: green;
}

.error {
  color: red;
}

其中的 CSS 变量用于控制按钮的背景颜色。

让我们看看最新的更改!在终端中运行 npm run dev 并查看它们。

当您访问 http://localhost:3000 时,您应该在浏览器中看到以下内容

我们的第一次语音到文本转换!

要采取的第一步是将必要的依赖项导入到我们的 <SpeechToText> 组件中

import React, { useRef, useState } from "react";
import SpeechRecognition, {
  useSpeechRecognition,
} from "react-speech-recognition";
import axios from "axios";

然后我们检查浏览器是否支持语音识别,如果不受支持则显示通知

const speechRecognitionSupported =
  SpeechRecognition.browserSupportsSpeechRecognition();

if (!speechRecognitionSupported) {
  return <div>Your browser does not support speech recognition.</div>;
}

接下来,让我们从 useSpeechRecognition() hook 中提取 transcriptresetTranscript

const { transcript, resetTranscript } = useSpeechRecognition();

这是处理 listening 状态所需的

const [listening, setListening] = useState(false);

我们还需要一个 ref 用于具有 contentEditable 属性的 div,然后我们需要将 ref 属性添加到其中并将 transcript 作为 children 传递

const textBodyRef = useRef(null);

…以及

<div
  className="words"
  contentEditable
  ref={textBodyRef}
  suppressContentEditableWarning={true}
  >
  {transcript}
</div>

这里我们需要做的最后一件事是创建一个触发语音识别的函数,并将该函数绑定到我们按钮的 onClick 事件监听器上。该按钮将 listening 设置为 true 并使其持续运行。当它处于该状态时,我们将禁用该按钮,以防止我们触发其他事件。

const startListening = () => {
  setListening(true);
  SpeechRecognition.startListening({
    continuous: true,
  });
};

…以及

<button
  type="button"
  onClick={startListening}
  style={{ "--bgColor": "blue" }}
  disabled={listening}
>
  Start
</button>

现在,单击该按钮应该会启动转录。

更多函数

好的,所以我们有一个可以开始监听的组件。但现在我们也需要它做一些其他事情,比如 stopListeningresetTexthandleConversion。让我们创建这些函数。

const stopListening = () => {
  setListening(false);
  SpeechRecognition.stopListening();
};

const resetText = () => {
  stopListening();
  resetTranscript();
  textBodyRef.current.innerText = "";
};

const handleConversion = async () => {}

每个函数都将被添加到相应按钮上的 onClick 事件监听器中

<button
  type="button"
  onClick={stopListening}
  style={{ "--bgColor": "orange" }}
  disabled={listening === false}
>
  Stop
</button>

<div className="button-container">
  <button
    type="button"
    onClick={resetText}
    style={{ "--bgColor": "red" }}
  >
    Reset
  </button>
  <button
    type="button"
    style={{ "--bgColor": "green" }}
    onClick={handleConversion}
  >
    Convert to pdf
  </button>
</div>

handleConversion 函数是异步的,因为我们最终将发出 API 请求。“停止”按钮具有禁用属性,该属性将在监听为 false 时触发。

如果我们重新启动服务器并刷新浏览器,我们现在可以在浏览器中启动、停止和重置语音转录。

现在我们需要应用程序通过将其转换为 PDF 文件来转录识别的语音。为此,我们需要来自 Express.js 的服务器端路径。

设置 API 路由

此路由的目的是获取一个文本文件,将其转换为 PDF,将该 PDF 写入我们的文件系统,然后向客户端发送响应。

要进行设置,我们将打开 `server/index.js` 文件并导入将用于写入和打开文件系统的 `html-pdf-node` 和 `fs` 依赖项。

const HTMLToPDF = require("html-pdf-node");
const fs = require("fs");
const cors = require("cors)

接下来,我们将设置我们的路由。

app.use(cors())
app.use(express.json())

app.post("/", (req, res) => {
  // etc.
})

然后我们继续定义在路由内使用 `html-pdf-node` 所需的选项。

let options = { format: "A4" };
let file = {
  content: `<html><body><pre style='font-size: 1.2rem'>${req.body.text}</pre></body></html>`,
};

`options` 对象接受一个值来设置纸张大小和样式。纸张大小遵循与我们在 Web 上通常使用的尺寸单位大不相同的系统。例如,A4 是典型的信纸尺寸

`file` 对象接受公共网站的 URL 或 HTML 标记。为了生成我们的 HTML 页面,我们将使用 `html`、`body`、`pre` HTML 标签和来自 `req.body` 的文本。

您可以应用任何您选择的样式。

接下来,我们将添加一个 `trycatch` 来处理可能出现的任何错误。

try {

} catch(error){
  console.log(error);
  res.status(500).send(error);
}

接下来,我们将使用 `html-pdf-node` 库中的 `generatePdf` 从我们的文件生成一个 `pdfBuffer`(原始 PDF 文件)并创建一个唯一的 `pdfName`。

HTMLToPDF.generatePdf(file, options).then((pdfBuffer) => {
  // console.log("PDF Buffer:-", pdfBuffer);
  const pdfName = "./data/speech" + Date.now() + ".pdf";

  // Next code here
}

从那里,我们使用文件系统模块来写入、读取和(是的,最终!)向客户端应用程序发送响应。

fs.writeFile(pdfName, pdfBuffer, function (writeError) {
  if (writeError) {
    return res
      .status(500)
      .json({ message: "Unable to write file. Try again." });
  }

  fs.readFile(pdfName, function (readError, readData) {
    if (!readError && readData) {
      // console.log({ readData });
      res.setHeader("Content-Type", "application/pdf");
      res.setHeader("Content-Disposition", "attachment");
      res.send(readData);
      return;
    }

    return res
      .status(500)
      .json({ message: "Unable to write file. Try again." });
  });
});

让我们详细分解一下。

  • `writeFile` 文件系统模块接受文件名、数据和一个回调函数,如果写入文件时出现问题,该函数可以返回错误消息。如果您正在使用提供错误端点的 CDN,则可以使用这些端点。
  • `readFile` 文件系统模块接受文件名和一个回调函数,该函数能够返回读取错误以及读取数据。一旦我们没有读取错误并且读取数据存在,我们将构建并向客户端发送响应。同样,如果您有 CDN 端点,则可以将其替换为您的 CDN 端点。
  • `res.setHeader("Content-Type", "application/pdf");` 告诉浏览器我们正在发送一个 PDF 文件。
  • `res.setHeader("Content-Disposition", "attachment");` 告诉浏览器使接收到的数据可下载。

由于 API 路由已准备就绪,因此我们可以在应用程序的 `http://localhost:4000` 中使用它。然后我们可以继续到应用程序的客户端部分来完成 `handleConversion` 函数。

处理转换

在我们可以开始处理 `handleConversion` 函数之前,我们需要创建一个状态来处理我们的 API 请求的加载、错误、成功和其他消息。我们将使用 React 的 `useState` 钩子来设置它。

const [response, setResponse] = useState({
  loading: false,
  message: "",
  error: false,
  success: false,
});

在 `handleConversion` 函数中,我们将检查网页加载完成后再运行我们的代码,并确保具有 `editable` 属性的 `div` 不为空。

if (typeof window !== "undefined") {
const userText = textBodyRef.current.innerText;
  // console.log(textBodyRef.current.innerText);

  if (!userText) {
    alert("Please speak or write some text.");
    return;
  }
}

我们继续将最终的 API 请求包装在 `trycatch` 中,处理可能出现的任何错误,并更新响应状态。

try {

} catch(error){
  setResponse({
    ...response,
    loading: false,
    error: true,
    message:
      "An unexpected error occurred. Text not converted. Please try again",
    success: false,
  });
}

接下来,我们为响应状态设置一些值,并为 `axios` 设置配置,并向服务器发出 POST 请求。

setResponse({
  ...response,
  loading: true,
  message: "",
  error: false,
  success: false,
});
const config = {
  headers: {
    "Content-Type": "application/json",
  },
  responseType: "blob",
};

const res = await axios.post(
  "http://localhost:4000",
  {
    text: textBodyRef.current.innerText,
  },
  config
);

一旦我们获得了成功的响应,我们将使用适当的值设置响应状态,并指示浏览器下载接收到的 PDF。

setResponse({
  ...response,
  loading: false,
  error: false,
  message:
    "Conversion was successful. Your download will start soon...",
  success: true,
});

// convert the received data to a file
const url = window.URL.createObjectURL(new Blob([res.data]));
// create an anchor element
const link = document.createElement("a");
// set the href of the created anchor element
link.href = url;
// add the download attribute, give the downloaded file a name
link.setAttribute("download", "yourfile.pdf");
// add the created anchor tag to the DOM
document.body.appendChild(link);
// force a click on the link to start a simulated download
link.click();

我们可以在 `contentEditable` `div` 下方使用以下内容来显示消息。

<div>
  {response.success && <i className="success">{response.message}</i>}
  {response.error && <i className="error">{response.message}</i>}
</div>

最终代码

我已经将所有内容打包到 GitHub 上,因此您可以查看服务器和客户端的完整源代码。